blob: 6baf04972375a59fdd83b4f38d301f0bea6d768c [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.CLASS_ACTIVITY;
import static com.android.utils.XmlUtils.getFirstSubTagByName;
import static com.android.utils.XmlUtils.getSubTagsByName;
import static com.android.xml.AndroidManifest.ATTRIBUTE_NAME;
import static com.android.xml.AndroidManifest.NODE_ACTIVITY;
import static com.android.xml.AndroidManifest.NODE_APPLICATION;
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.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.tools.lint.client.api.JavaEvaluator;
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.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.JavaContext;
import com.android.tools.lint.detector.api.Lint;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.SourceCodeScanner;
import com.android.tools.lint.detector.api.XmlContext;
import com.android.tools.lint.detector.api.XmlScanner;
import com.android.utils.XmlUtils;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiField;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import org.jetbrains.uast.UCallExpression;
import org.jetbrains.uast.UClass;
import org.jetbrains.uast.UElement;
import org.jetbrains.uast.UExpression;
import org.jetbrains.uast.UastUtils;
import org.jetbrains.uast.util.UastExpressionUtils;
import org.jetbrains.uast.visitor.AbstractUastVisitor;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
/** Check if the usage of App Indexing is correct. */
public class AppIndexingApiDetector extends Detector implements XmlScanner, SourceCodeScanner {
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_APP_INDEXING =
Issue.create(
"GoogleAppIndexingWarning",
"Missing support for Firebase 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")
.setEnabledByDefault(false);
public static final Issue ISSUE_APP_INDEXING_API =
Issue.create(
"GoogleAppIndexingApiWarning",
"Missing support for Firebase 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 APP_INDEX_START = "start";
private static final String APP_INDEX_END = "end";
private static final String APP_INDEX_VIEW = "view";
private static final String APP_INDEX_VIEW_END = "viewEnd";
private static final String CLIENT_CONNECT = "connect";
private static final String CLIENT_DISCONNECT = "disconnect";
private static final String ADD_API = "addApi";
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";
// ---- Implements XmlScanner ----
@Override
@Nullable
public Collection<String> getApplicableElements() {
return Collections.singletonList(NODE_APPLICATION);
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element application) {
boolean applicationHasActionView = false;
for (Element activity : XmlUtils.getSubTagsByName(application, NODE_ACTIVITY)) {
for (Element intent : XmlUtils.getSubTagsByName(activity, NODE_INTENT)) {
boolean actionView = AppLinksValidDetector.hasActionView(intent);
if (actionView) {
applicationHasActionView = true;
}
}
}
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 visitClass(@NonNull JavaContext context, @NonNull UClass 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 AbstractUastVisitor {
private final JavaContext mContext;
private final UClass mCls;
private final List<UCallExpression> mStartMethods;
private final List<UCallExpression> mEndMethods;
private final List<UCallExpression> mConnectMethods;
private final List<UCallExpression> mDisconnectMethods;
private boolean mHasAddAppIndexApi;
MethodVisitor(JavaContext context, UClass cls) {
mCls = cls;
mContext = context;
mStartMethods = Lists.newArrayListWithExpectedSize(2);
mEndMethods = Lists.newArrayListWithExpectedSize(2);
mConnectMethods = Lists.newArrayListWithExpectedSize(2);
mDisconnectMethods = Lists.newArrayListWithExpectedSize(2);
}
@Override
public boolean visitClass(UClass aClass) {
//noinspection SimplifiableIfStatement
if (aClass.getPsi().equals(mCls.getPsi())) {
return super.visitClass(aClass);
} else {
// Don't go into inner classes
return true;
}
}
@Override
public void afterVisitClass(UClass node) {
report();
}
@Override
public boolean visitCallExpression(UCallExpression node) {
if (UastExpressionUtils.isMethodCall(node)) {
visitMethodCallExpression(node);
}
return super.visitCallExpression(node);
}
private void visitMethodCallExpression(UCallExpression node) {
String methodName = node.getMethodName();
if (methodName == null) {
return;
}
JavaEvaluator evaluator = mContext.getEvaluator();
switch (methodName) {
case APP_INDEX_START:
if (evaluator.isMemberInClass(node.resolve(), APP_INDEXING_API_CLASS)) {
mStartMethods.add(node);
}
break;
case APP_INDEX_END:
if (evaluator.isMemberInClass(node.resolve(), APP_INDEXING_API_CLASS)) {
mEndMethods.add(node);
}
break;
case APP_INDEX_VIEW:
if (evaluator.isMemberInClass(node.resolve(), APP_INDEXING_API_CLASS)) {
mStartMethods.add(node);
}
break;
case APP_INDEX_VIEW_END:
if (evaluator.isMemberInClass(node.resolve(), APP_INDEXING_API_CLASS)) {
mEndMethods.add(node);
}
break;
case CLIENT_CONNECT:
if (evaluator.isMemberInClass(node.resolve(), GOOGLE_API_CLIENT_CLASS)) {
mConnectMethods.add(node);
}
break;
case CLIENT_DISCONNECT:
if (evaluator.isMemberInClass(node.resolve(), GOOGLE_API_CLIENT_CLASS)) {
mDisconnectMethods.add(node);
}
break;
case ADD_API:
if (evaluator.isMemberInClass(
node.resolve(), GOOGLE_API_CLIENT_BUILDER_CLASS)) {
if (evaluator.isMemberInClass(
node.resolve(), GOOGLE_API_CLIENT_BUILDER_CLASS)) {
List<UExpression> args = node.getValueArguments();
if (!args.isEmpty()) {
PsiElement resolved = UastUtils.tryResolve(args.get(0));
if (resolved instanceof PsiField
&& evaluator.isMemberInClass(
(PsiField) resolved, API_CLASS)) {
mHasAddAppIndexApi = true;
}
}
break;
}
}
}
}
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 (UCallExpression call : mStartMethods) {
mContext.report(
ISSUE_APP_INDEXING_API,
call,
mContext.getNameLocation(call),
"Missing support for Firebase App Indexing in the manifest");
}
for (UCallExpression call : mEndMethods) {
mContext.report(
ISSUE_APP_INDEXING_API,
call,
mContext.getNameLocation(call),
"Missing support for Firebase 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 Firebase App Indexing API");
return;
}
for (UCallExpression startNode : mStartMethods) {
List<UExpression> expressions = startNode.getValueArguments();
if (expressions.isEmpty()) {
continue;
}
UExpression startClient = expressions.get(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.asSourceString());
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.asSourceString());
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 (UCallExpression endNode : mEndMethods) {
List<UExpression> expressions = endNode.getValueArguments();
if (expressions.isEmpty()) {
continue;
}
UExpression endClient = expressions.get(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.asSourceString());
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.asSourceString());
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.
*
* @param context The context to check in.
*/
@NonNull
private static Set<String> getActivitiesToCheck(Context context) {
Set<String> activitiesToCheck = Sets.newHashSet();
Document doc = context.getMainProject().getMergedManifest();
if (doc == null) {
return Collections.emptySet();
}
for (Element child : XmlUtils.getSubTags(doc)) {
if (child.getNodeName().equals(NODE_MANIFEST)) {
for (Element app : getSubTagsByName(child, NODE_APPLICATION)) {
for (Element act : getSubTagsByName(app, NODE_ACTIVITY)) {
for (Element intent : getSubTagsByName(act, NODE_INTENT)) {
boolean hasData = getFirstSubTagByName(intent, NODE_DATA) != null;
if (hasData && act.hasAttributeNS(ANDROID_URI, ATTRIBUTE_NAME)) {
String activityName = Lint.resolveManifestName(act);
activitiesToCheck.add(activityName);
}
}
}
}
}
}
return activitiesToCheck;
}
/**
* 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(UExpression argument, List<UCallExpression> list) {
for (UCallExpression call : list) {
List<UExpression> expressions = call.getValueArguments();
if (!expressions.isEmpty()) {
UExpression argument2 = expressions.get(0);
if (argument.asSourceString().equals(argument2.asSourceString())) {
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(UExpression operand, List<UCallExpression> list) {
for (UCallExpression method : list) {
UElement operand2 = method.getReceiver();
if (operand2 != null && operand.asSourceString().equals(operand2.asSourceString())) {
return true;
}
}
return false;
}
}