| /* |
| * 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_EXPORTED; |
| import static com.android.SdkConstants.ATTR_HOST; |
| import static com.android.SdkConstants.ATTR_PATH; |
| import static com.android.SdkConstants.ATTR_PATH_PREFIX; |
| import static com.android.SdkConstants.ATTR_SCHEME; |
| import static com.android.SdkConstants.CLASS_ACTIVITY; |
| 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_ACTIVITY; |
| import static com.android.xml.AndroidManifest.NODE_APPLICATION; |
| 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 static com.android.xml.AndroidManifest.NODE_MANIFEST; |
| |
| 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.JavaEvaluator; |
| import com.android.tools.lint.client.api.LintClient; |
| import com.android.tools.lint.client.api.XmlParser; |
| import com.android.tools.lint.detector.api.Category; |
| import com.android.tools.lint.detector.api.Context; |
| import com.android.tools.lint.detector.api.Detector; |
| import com.android.tools.lint.detector.api.Detector.JavaPsiScanner; |
| import com.android.tools.lint.detector.api.Detector.XmlScanner; |
| import com.android.tools.lint.detector.api.Implementation; |
| import com.android.tools.lint.detector.api.Issue; |
| import com.android.tools.lint.detector.api.JavaContext; |
| import com.android.tools.lint.detector.api.LintUtils; |
| 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 com.google.common.collect.Lists; |
| import com.google.common.collect.Sets; |
| import com.intellij.psi.JavaRecursiveElementVisitor; |
| import com.intellij.psi.PsiAnonymousClass; |
| import com.intellij.psi.PsiClass; |
| import com.intellij.psi.PsiElement; |
| import com.intellij.psi.PsiExpression; |
| import com.intellij.psi.PsiField; |
| import com.intellij.psi.PsiMethodCallExpression; |
| |
| 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 java.io.File; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.EnumSet; |
| import java.util.List; |
| import java.util.Set; |
| |
| |
| /** |
| * Check if the usage of App Indexing is correct. |
| */ |
| public class AppIndexingApiDetector extends Detector implements XmlScanner, JavaPsiScanner { |
| |
| private static final Implementation URL_IMPLEMENTATION = new Implementation( |
| AppIndexingApiDetector.class, Scope.MANIFEST_SCOPE); |
| |
| @SuppressWarnings("unchecked") |
| private static final Implementation APP_INDEXING_API_IMPLEMENTATION = |
| new Implementation( |
| AppIndexingApiDetector.class, |
| EnumSet.of(Scope.JAVA_FILE, Scope.MANIFEST), |
| Scope.JAVA_FILE_SCOPE, Scope.MANIFEST_SCOPE); |
| |
| public static final Issue ISSUE_URL_ERROR = Issue.create( |
| "GoogleAppIndexingUrlError", //$NON-NLS-1$ |
| "URL not supported by app for Google App Indexing", |
| "Ensure the URL is supported by your app, to get installs and traffic to your" |
| + " app from Google Search.", |
| Category.USABILITY, 5, Severity.ERROR, URL_IMPLEMENTATION) |
| .addMoreInfo("https://g.co/AppIndexing/AndroidStudio"); |
| |
| public static final Issue ISSUE_APP_INDEXING = |
| Issue.create( |
| "GoogleAppIndexingWarning", //$NON-NLS-1$ |
| "Missing support for Google App Indexing", |
| "Adds URLs to get your app into the Google index, to get installs" |
| + " and traffic to your app from Google Search.", |
| Category.USABILITY, 5, Severity.WARNING, URL_IMPLEMENTATION) |
| .addMoreInfo("https://g.co/AppIndexing/AndroidStudio"); |
| |
| public static final Issue ISSUE_APP_INDEXING_API = |
| Issue.create( |
| "GoogleAppIndexingApiWarning", //$NON-NLS-1$ |
| "Missing support for Google App Indexing Api", |
| "Adds URLs to get your app into the Google index, to get installs" |
| + " and traffic to your app from Google Search.", |
| Category.USABILITY, 5, Severity.WARNING, APP_INDEXING_API_IMPLEMENTATION) |
| .addMoreInfo("https://g.co/AppIndexing/AndroidStudio") |
| .setEnabledByDefault(false); |
| |
| private static final String[] PATH_ATTR_LIST = new String[]{ATTR_PATH_PREFIX, ATTR_PATH}; |
| private static final String SCHEME_MISSING = "android:scheme is missing"; |
| private static final String HOST_MISSING = "android:host is missing"; |
| private static final String DATA_MISSING = "Missing data element"; |
| private static final String URL_MISSING = "Missing URL for the intent filter"; |
| private static final String NOT_BROWSABLE |
| = "Activity supporting ACTION_VIEW is not set as BROWSABLE"; |
| private static final String ILLEGAL_NUMBER = "android:port is not a legal number"; |
| |
| private static final String APP_INDEX_START = "start"; //$NON-NLS-1$ |
| private static final String APP_INDEX_END = "end"; //$NON-NLS-1$ |
| private static final String APP_INDEX_VIEW = "view"; //$NON-NLS-1$ |
| private static final String APP_INDEX_VIEW_END = "viewEnd"; //$NON-NLS-1$ |
| private static final String CLIENT_CONNECT = "connect"; //$NON-NLS-1$ |
| private static final String CLIENT_DISCONNECT = "disconnect"; //$NON-NLS-1$ |
| private static final String ADD_API = "addApi"; //$NON-NLS-1$ |
| |
| private static final String APP_INDEXING_API_CLASS |
| = "com.google.android.gms.appindexing.AppIndexApi"; |
| private static final String GOOGLE_API_CLIENT_CLASS |
| = "com.google.android.gms.common.api.GoogleApiClient"; |
| private static final String GOOGLE_API_CLIENT_BUILDER_CLASS |
| = "com.google.android.gms.common.api.GoogleApiClient.Builder"; |
| private static final String API_CLASS = "com.google.android.gms.appindexing.AppIndex"; |
| |
| public enum IssueType { |
| SCHEME_MISSING(AppIndexingApiDetector.SCHEME_MISSING), |
| HOST_MISSING(AppIndexingApiDetector.HOST_MISSING), |
| DATA_MISSING(AppIndexingApiDetector.DATA_MISSING), |
| URL_MISSING(AppIndexingApiDetector.URL_MISSING), |
| NOT_BROWSABLE(AppIndexingApiDetector.NOT_BROWSABLE), |
| ILLEGAL_NUMBER(AppIndexingApiDetector.ILLEGAL_NUMBER), |
| EMPTY_FIELD("cannot be empty"), |
| MISSING_SLASH("attribute should start with '/'"), |
| UNKNOWN("unknown error type"); |
| |
| private final String message; |
| |
| IssueType(String str) { |
| this.message = str; |
| } |
| |
| public static IssueType parse(String str) { |
| for (IssueType type : IssueType.values()) { |
| if (str.contains(type.message)) { |
| return type; |
| } |
| } |
| return UNKNOWN; |
| } |
| } |
| |
| // ---- Implements XmlScanner ---- |
| @Override |
| @Nullable |
| public Collection<String> getApplicableElements() { |
| return Collections.singletonList(NODE_APPLICATION); |
| } |
| |
| @Override |
| public void visitElement(@NonNull XmlContext context, @NonNull Element application) { |
| List<Element> activities = extractChildrenByName(application, NODE_ACTIVITY); |
| boolean applicationHasActionView = false; |
| for (Element activity : activities) { |
| List<Element> intents = extractChildrenByName(activity, NODE_INTENT); |
| boolean activityHasActionView = false; |
| for (Element intent : intents) { |
| boolean actionView = hasActionView(intent); |
| if (actionView) { |
| activityHasActionView = true; |
| } |
| visitIntent(context, intent); |
| } |
| if (activityHasActionView) { |
| applicationHasActionView = true; |
| if (activity.hasAttributeNS(ANDROID_URI, ATTR_EXPORTED)) { |
| Attr exported = activity.getAttributeNodeNS(ANDROID_URI, ATTR_EXPORTED); |
| if (!exported.getValue().equals("true")) { |
| // Report error if the activity supporting action view is not exported. |
| context.report(ISSUE_URL_ERROR, activity, |
| context.getLocation(activity), |
| "Activity supporting ACTION_VIEW is not exported"); |
| } |
| } |
| } |
| } |
| if (!applicationHasActionView && !context.getProject().isLibrary()) { |
| // Report warning if there is no activity that supports action view. |
| context.report(ISSUE_APP_INDEXING, application, context.getLocation(application), |
| // This error message is more verbose than the other app indexing lint warnings, because it |
| // shows up on a blank project, and we want to make it obvious by just looking at the error |
| // message what this is |
| "App is not indexable by Google Search; consider adding at least one Activity with an ACTION-VIEW " + |
| "intent filter. See issue explanation for more details."); |
| } |
| } |
| |
| @Nullable |
| @Override |
| public List<String> applicableSuperClasses() { |
| return Collections.singletonList(CLASS_ACTIVITY); |
| } |
| |
| @Override |
| public void checkClass(@NonNull JavaContext context, @NonNull PsiClass declaration) { |
| if (declaration.getName() == null) { |
| return; |
| } |
| |
| // In case linting the base class itself. |
| if (!context.getEvaluator().extendsClass(declaration, CLASS_ACTIVITY, true)) { |
| return; |
| } |
| |
| declaration.accept(new MethodVisitor(context, declaration)); |
| } |
| |
| static class MethodVisitor extends JavaRecursiveElementVisitor { |
| private final JavaContext mContext; |
| private final PsiClass mCls; |
| |
| private final List<PsiMethodCallExpression> mStartMethods; |
| private final List<PsiMethodCallExpression> mEndMethods; |
| private final List<PsiMethodCallExpression> mConnectMethods; |
| private final List<PsiMethodCallExpression> mDisconnectMethods; |
| private boolean mHasAddAppIndexApi; |
| |
| MethodVisitor(JavaContext context, PsiClass cls) { |
| mCls = cls; |
| mContext = context; |
| mStartMethods = Lists.newArrayListWithExpectedSize(2); |
| mEndMethods = Lists.newArrayListWithExpectedSize(2); |
| mConnectMethods = Lists.newArrayListWithExpectedSize(2); |
| mDisconnectMethods = Lists.newArrayListWithExpectedSize(2); |
| } |
| |
| @Override |
| public void visitClass(PsiClass aClass) { |
| if (aClass == mCls) { |
| super.visitClass(aClass); |
| report(); |
| } // else: don't go into inner classes |
| } |
| |
| @Override |
| public void visitMethodCallExpression(PsiMethodCallExpression node) { |
| super.visitMethodCallExpression(node); |
| |
| String methodName = node.getMethodExpression().getReferenceName(); |
| if (methodName == null) { |
| return; |
| } |
| |
| JavaEvaluator evaluator = mContext.getEvaluator(); |
| if (methodName.equals(APP_INDEX_START)) { |
| if (evaluator.isMemberInClass(node.resolveMethod(), APP_INDEXING_API_CLASS)) { |
| mStartMethods.add(node); |
| } |
| } else if (methodName.equals(APP_INDEX_END)) { |
| if (evaluator.isMemberInClass(node.resolveMethod(), APP_INDEXING_API_CLASS)) { |
| mEndMethods.add(node); |
| } |
| } else if (methodName.equals(APP_INDEX_VIEW)) { |
| if (evaluator.isMemberInClass(node.resolveMethod(), APP_INDEXING_API_CLASS)) { |
| mStartMethods.add(node); |
| } |
| } else if (methodName.equals(APP_INDEX_VIEW_END)) { |
| if (evaluator.isMemberInClass(node.resolveMethod(), APP_INDEXING_API_CLASS)) { |
| mEndMethods.add(node); |
| } |
| } else if (methodName.equals(CLIENT_CONNECT)) { |
| if (evaluator.isMemberInClass(node.resolveMethod(), GOOGLE_API_CLIENT_CLASS)) { |
| mConnectMethods.add(node); |
| } |
| } else if (methodName.equals(CLIENT_DISCONNECT)) { |
| if (evaluator.isMemberInClass(node.resolveMethod(), GOOGLE_API_CLIENT_CLASS)) { |
| mDisconnectMethods.add(node); |
| } |
| } else if (methodName.equals(ADD_API)) { |
| if (evaluator.isMemberInClass(node.resolveMethod(), GOOGLE_API_CLIENT_BUILDER_CLASS)) { |
| PsiExpression[] args = node.getArgumentList().getExpressions(); |
| if (args.length > 0) { |
| PsiElement resolved = evaluator.resolve(args[0]); |
| if (resolved instanceof PsiField && |
| evaluator.isMemberInClass((PsiField) resolved, API_CLASS)) { |
| mHasAddAppIndexApi = true; |
| } |
| } |
| } |
| } |
| } |
| |
| @Override |
| public void visitAnonymousClass(PsiAnonymousClass aClass) { |
| // Don't jump into inner classes |
| } |
| |
| private void report() { |
| // finds the activity classes that need app activity annotation |
| Set<String> activitiesToCheck = getActivitiesToCheck(mContext); |
| |
| // app indexing API used but no support in manifest |
| boolean hasIntent = activitiesToCheck.contains(mCls.getQualifiedName()); |
| if (!hasIntent) { |
| for (PsiMethodCallExpression call : mStartMethods) { |
| mContext.report(ISSUE_APP_INDEXING_API, call, |
| mContext.getNameLocation(call), |
| "Missing support for Google App Indexing in the manifest"); |
| } |
| for (PsiMethodCallExpression call : mEndMethods) { |
| mContext.report(ISSUE_APP_INDEXING_API, call, |
| mContext.getNameLocation(call), |
| "Missing support for Google App Indexing in the manifest"); |
| } |
| return; |
| } |
| |
| // `AppIndex.AppIndexApi.start / end / view / viewEnd` should exist |
| if (mStartMethods.isEmpty() && mEndMethods.isEmpty()) { |
| mContext.report(ISSUE_APP_INDEXING_API, mCls, |
| mContext.getNameLocation(mCls), |
| "Missing support for Google App Indexing API"); |
| return; |
| } |
| |
| for (PsiMethodCallExpression startNode : mStartMethods) { |
| PsiExpression[] expressions = startNode.getArgumentList().getExpressions(); |
| if (expressions.length == 0) { |
| continue; |
| } |
| PsiExpression startClient = expressions[0]; |
| |
| // GoogleApiClient should `addApi(AppIndex.APP_INDEX_API)` |
| if (!mHasAddAppIndexApi) { |
| String message = String.format( |
| "GoogleApiClient `%1$s` has not added support for App Indexing API", |
| startClient.getText()); |
| mContext.report(ISSUE_APP_INDEXING_API, startClient, |
| mContext.getLocation(startClient), message); |
| } |
| |
| // GoogleApiClient `connect` should exist |
| if (!hasOperand(startClient, mConnectMethods)) { |
| String message = String.format("GoogleApiClient `%1$s` is not connected", |
| startClient.getText()); |
| mContext.report(ISSUE_APP_INDEXING_API, startClient, |
| mContext.getLocation(startClient), message); |
| } |
| |
| // `AppIndex.AppIndexApi.end` should pair with `AppIndex.AppIndexApi.start` |
| if (!hasFirstArgument(startClient, mEndMethods)) { |
| mContext.report(ISSUE_APP_INDEXING_API, startNode, |
| mContext.getNameLocation(startNode), |
| "Missing corresponding `AppIndex.AppIndexApi.end` method"); |
| } |
| } |
| |
| for (PsiMethodCallExpression endNode : mEndMethods) { |
| PsiExpression[] expressions = endNode.getArgumentList().getExpressions(); |
| if (expressions.length == 0) { |
| continue; |
| } |
| PsiExpression endClient = expressions[0]; |
| |
| // GoogleApiClient should `addApi(AppIndex.APP_INDEX_API)` |
| if (!mHasAddAppIndexApi) { |
| String message = String.format( |
| "GoogleApiClient `%1$s` has not added support for App Indexing API", |
| endClient.getText()); |
| mContext.report(ISSUE_APP_INDEXING_API, endClient, |
| mContext.getLocation(endClient), message); |
| } |
| |
| // GoogleApiClient `disconnect` should exist |
| if (!hasOperand(endClient, mDisconnectMethods)) { |
| String message = String.format("GoogleApiClient `%1$s`" |
| + " is not disconnected", endClient.getText()); |
| mContext.report(ISSUE_APP_INDEXING_API, endClient, |
| mContext.getLocation(endClient), message); |
| } |
| |
| // `AppIndex.AppIndexApi.start` should pair with `AppIndex.AppIndexApi.end` |
| if (!hasFirstArgument(endClient, mStartMethods)) { |
| mContext.report(ISSUE_APP_INDEXING_API, endNode, |
| mContext.getNameLocation(endNode), |
| "Missing corresponding `AppIndex.AppIndexApi.start` method"); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Gets names of activities which needs app indexing. i.e. the activities have data tag in their |
| * intent filters. |
| * TODO: Cache the activities to speed up batch lint. |
| * |
| * @param context The context to check in. |
| */ |
| private static Set<String> getActivitiesToCheck(Context context) { |
| Set<String> activitiesToCheck = Sets.newHashSet(); |
| List<File> manifestFiles = context.getProject().getManifestFiles(); |
| XmlParser xmlParser = context.getDriver().getClient().getXmlParser(); |
| if (xmlParser != null) { |
| // TODO: Avoid visit all manifest files before enable this check by default. |
| for (File manifest : manifestFiles) { |
| XmlContext xmlContext = |
| new XmlContext(context.getDriver(), context.getProject(), |
| null, manifest, null, xmlParser); |
| Document doc = xmlParser.parseXml(xmlContext); |
| if (doc != null) { |
| List<Element> children = LintUtils.getChildren(doc); |
| for (Element child : children) { |
| if (child.getNodeName().equals(NODE_MANIFEST)) { |
| List<Element> apps = extractChildrenByName(child, NODE_APPLICATION); |
| for (Element app : apps) { |
| List<Element> acts = extractChildrenByName(app, NODE_ACTIVITY); |
| for (Element act : acts) { |
| List<Element> intents = extractChildrenByName(act, NODE_INTENT); |
| for (Element intent : intents) { |
| List<Element> data = extractChildrenByName(intent, |
| NODE_DATA); |
| if (!data.isEmpty() && act.hasAttributeNS( |
| ANDROID_URI, ATTRIBUTE_NAME)) { |
| Attr attr = act.getAttributeNodeNS( |
| ANDROID_URI, ATTRIBUTE_NAME); |
| String activityName = attr.getValue(); |
| int dotIndex = activityName.indexOf('.'); |
| if (dotIndex <= 0) { |
| String pkg = context.getMainProject().getPackage(); |
| if (pkg != null) { |
| if (dotIndex == 0) { |
| activityName = pkg + activityName; |
| } |
| else { |
| activityName = pkg + '.' + activityName; |
| } |
| } |
| } |
| activitiesToCheck.add(activityName); |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| return activitiesToCheck; |
| } |
| |
| private static void visitIntent(@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; |
| List<Element> children = extractChildrenByName(intent, NODE_DATA); |
| for (Element data : children) { |
| 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_URL_ERROR, firstData, context.getLocation(firstData), |
| SCHEME_MISSING); |
| } |
| |
| if ((hasPath || hasPort) && !hasHost) { |
| context.report(ISSUE_URL_ERROR, firstData, context.getLocation(firstData), |
| 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_URL_ERROR, intent, context.getLocation(intent), |
| DATA_MISSING); |
| } 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_URL_ERROR, firstData, context.getLocation(firstData), |
| URL_MISSING); |
| } |
| } |
| |
| // 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_APP_INDEXING, intent, context.getLocation(intent), |
| NOT_BROWSABLE); |
| } |
| |
| if (actionView && !hasScheme) { |
| context.report(ISSUE_APP_INDEXING, intent, context.getLocation(intent), |
| "Missing URL"); |
| } |
| } |
| |
| /** |
| * Check if the intent filter supports action view. |
| * |
| * @param intent the intent filter |
| * @return true if it does |
| */ |
| private static boolean hasActionView(@NonNull Element intent) { |
| List<Element> children = extractChildrenByName(intent, NODE_ACTION); |
| for (Element action : children) { |
| 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; |
| } |
| |
| /** |
| * Check if the intent filter is browsable. |
| * |
| * @param intent the intent filter |
| * @return true if it does |
| */ |
| private static boolean isBrowsable(@NonNull Element intent) { |
| List<Element> children = extractChildrenByName(intent, NODE_CATEGORY); |
| for (Element e : children) { |
| 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; |
| } |
| |
| /** |
| * Check if the data node contains http schema |
| * |
| * @param data the data node |
| * @return true if it does |
| */ |
| private static boolean isHttpSchema(@NonNull 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(@NonNull XmlContext context, @NonNull 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_URL_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_URL_ERROR, attr, context.getLocation(attr), |
| ILLEGAL_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_URL_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(); |
| } |
| |
| /** |
| * If a method with a certain argument exists in the list of methods. |
| * |
| * @param argument The first argument of the method. |
| * @param list The methods list. |
| * @return If such a method exists in the list. |
| */ |
| private static boolean hasFirstArgument(PsiExpression argument, List<PsiMethodCallExpression> list) { |
| for (PsiMethodCallExpression call : list) { |
| PsiExpression[] expressions = call.getArgumentList().getExpressions(); |
| if (expressions.length > 0) { |
| PsiExpression argument2 = expressions[0]; |
| if (argument.getText().equals(argument2.getText())) { |
| return true; |
| } |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * If a method with a certain operand exists in the list of methods. |
| * |
| * @param operand The operand of the method. |
| * @param list The methods list. |
| * @return If such a method exists in the list. |
| */ |
| private static boolean hasOperand(PsiExpression operand, List<PsiMethodCallExpression> list) { |
| for (PsiMethodCallExpression method : list) { |
| PsiElement operand2 = method.getMethodExpression().getQualifier(); |
| if (operand2 != null && operand.getText().equals(operand2.getText())) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private static List<Element> extractChildrenByName(@NonNull Element node, |
| @NonNull String name) { |
| List<Element> result = Lists.newArrayList(); |
| List<Element> children = LintUtils.getChildren(node); |
| for (Element child : children) { |
| if (child.getNodeName().equals(name)) { |
| result.add(child); |
| } |
| } |
| return result; |
| } |
| } |