blob: 95490f342936e4597e158c09b54b17996f6dd01a [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.google.appindexing.api;
import com.android.SdkConstants;
import com.android.annotations.VisibleForTesting;
import com.android.tools.idea.gradle.dsl.model.GradleBuildModel;
import com.android.tools.idea.gradle.dsl.model.dependencies.ArtifactDependencyModel;
import com.android.tools.idea.gradle.dsl.model.dependencies.ArtifactDependencySpec;
import com.android.tools.idea.gradle.dsl.model.dependencies.CommonConfigurationNames;
import com.android.tools.idea.gradle.dsl.model.dependencies.DependenciesModel;
import com.google.appindexing.util.DeepLinkUtils;
import com.google.appindexing.util.ManifestUtils;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.intellij.ide.highlighter.JavaFileType;
import com.intellij.lang.java.JavaLanguage;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.module.ModuleUtilCore;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.psi.*;
import com.intellij.psi.codeStyle.CodeStyleManager;
import com.intellij.psi.codeStyle.JavaCodeStyleManager;
import com.intellij.psi.util.InheritanceUtil;
import com.intellij.psi.xml.XmlFile;
import com.intellij.psi.xml.XmlTag;
import com.intellij.util.text.VersionComparatorUtil;
import com.siyeh.ig.psiutils.ImportUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.android.tools.idea.gradle.dsl.model.values.GradleValue.getValues;
/**
* A class for detecting the situation where developers need App Indexing API code and inserting
* the API code in their projects.
*/
public final class ApiCreator {
static private final String METHOD_GET_ACTION = "getIndexApiAction";
static private final String CLASS_ACTION = "Action";
static private final String CLASS_FIREBASE_USER_ACTION = "FirebaseUserActions";
static private final String CLASS_BUILDERS_ACTIONS = "Actions";
static private final String CLASS_ACTION_FULL = "com.google.firebase.appindexing.Action";
static private final String CLASS_FIREBASE_USER_ACTION_FULL = "com.google.firebase.appindexing.FirebaseUserActions";
static private final String CLASS_BUILDERS_ACTIONS_FULL = "com.google.firebase.appindexing.builders.Actions";
static private final List<String> SIGNATURE_ON_START = Lists.newArrayList();
static private final List<String> SIGNATURE_ON_STOP = Lists.newArrayList();
static private final String ON_START_FORMAT = "@Override\n" +
"public void onStart(){\n" +
" super.onStart();\n" +
" \n" +
" // ATTENTION: This was auto-generated to implement the App Indexing API.\n" +
" // See https://g.co/AppIndexing/AndroidStudio for more information.\n" +
" %1$s.getInstance().start(%2$s());\n" +
"}";
static private final String ON_STOP_FORMAT = "@Override\n" +
"public void onStop() {\n" +
" \n" +
" // ATTENTION: This was auto-generated to implement the App Indexing API.\n" +
" // See https://g.co/AppIndexing/AndroidStudio for more information.\n" +
" %1$s.getInstance().end(%2$s());\n" +
" super.onStop();\n" +
"}";
static private final String GET_ACTION_FORMAT = "/**\n" +
" * ATTENTION: This was auto-generated to implement the App Indexing API.\n" +
" * See https://g.co/AppIndexing/AndroidStudio for more information.\n" +
" */\n" +
"public %4$s %1$s() {\n" +
" return %5$s.newView(\"%2$s\", \"%3$s\");\n" +
"}";
static private final String FIREBASE_USER_ACTION_START_TEMPLATE = "%1$s.getInstance().start(%2$s());";
static private final String FIREBASE_USER_ACTION_END_TEMPLATE = "%1$s.getInstance().end(%2$s());";
// Constant to look up existing usage.
static private final String FIREBASE_USER_ACTION_START = "FirebaseUserActions.getInstance().start";
// Constant to look up existing usage.
static private final String FIREBASE_USER_ACTION_END = "FirebaseUserActions.getInstance().end";
static private final List<String> COMMENT_IN_JAVA = Lists
.newArrayList("// ATTENTION: This was auto-generated to implement the App Indexing API.",
"// See https://g.co/AppIndexing/AndroidStudio for more information.");
/* To prompt developers to update deep link url, sets default url to a noticeable wrong url */
static private final String DEFAULT_DEEP_LINK_URL = "http://[ENTER-YOUR-URL-HERE]";
// Dependencies list of App Indexing API.
// It should be compatible with https://firebase.google.com/docs/app-indexing/android/app
// We'll check if all dependencies are added to project and module build.gradle files before generating code.
private static final String APP_INDEXING_LIB_DEPENDENCY = "com.google.firebase:firebase-appindexing:10.0.1+";
private static final String APP_INDEXING_CLASSPATH_DEPENDENCY = "com.google.gms:google-services:3.0.0+";
private static final String APP_INDEXING_PLUGIN_DEPENDENCY = "com.google.gms.google-services";
private Project myProject = null;
private Module myModule = null;
private PsiFile myFile = null;
private PsiClass myActivity = null;
private PsiElementFactory myFactory = null;
private CodeStyleManager myCodeStyleManager = null;
private PsiCodeBlock myOnStart = null;
private PsiCodeBlock myOnStop = null;
/* The App Indexing API statements in onStart / onStop method */
private List<PsiStatement> myStartStatements = Lists.newArrayList();
private List<PsiStatement> myEndStatements = Lists.newArrayList();
private Map<String, String> myImportClasses = Maps.newHashMap();
public ApiCreator(@NotNull Project project, @NotNull Editor editor, @NotNull PsiFile file) {
myProject = project;
myFile = file;
myModule = ModuleUtilCore.findModuleForFile(myFile.getVirtualFile(), myProject);
if (myFile instanceof PsiJavaFile) {
PsiElement element = myFile.findElementAt(editor.getCaretModel().getOffset());
if (element != null) {
myActivity = getSurroundingInheritingClass(element, SdkConstants.CLASS_ACTIVITY);
if (myActivity != null) {
myOnStart = getMethodBodyByName("onStart", SIGNATURE_ON_START, myActivity);
myOnStop = getMethodBodyByName("onStop", SIGNATURE_ON_STOP, myActivity);
if (myOnStart != null) {
myStartStatements = StatementFilter.filterCodeBlock(FIREBASE_USER_ACTION_START, myOnStart);
}
if (myOnStop != null) {
myEndStatements = StatementFilter.filterCodeBlock(FIREBASE_USER_ACTION_END, myOnStop);
}
}
}
}
myFactory = (PsiElementFactory)JVMElementFactories.getFactory(JavaLanguage.INSTANCE, myProject);
myCodeStyleManager = CodeStyleManager.getInstance(myProject);
myImportClasses.put(CLASS_ACTION, CLASS_ACTION_FULL);
myImportClasses.put(CLASS_BUILDERS_ACTIONS, CLASS_BUILDERS_ACTIONS_FULL);
myImportClasses.put(CLASS_FIREBASE_USER_ACTION, CLASS_FIREBASE_USER_ACTION_FULL);
}
/**
* If the current caret's position is eligible for creating App Indexing API code,
* i.e. the caret is inside an 'Activity' class
* and the class does not call app indexing api.
*
* @return true if the current caret's position needs app indexing generating intention.
*/
public static boolean eligibleForInsertingAppIndexingApiCode(@NotNull Editor editor, @NotNull PsiFile file) {
if (!(file instanceof PsiJavaFile)) return false;
PsiElement element = file.findElementAt(editor.getCaretModel().getOffset());
if (element == null) return false;
PsiClass activity = getSurroundingInheritingClass(element, SdkConstants.CLASS_ACTIVITY);
if (activity == null) return false;
if (!hasEnoughDependencies(ModuleUtilCore.findModuleForPsiElement(element))) return false;
PsiCodeBlock onStart = getMethodBodyByName("onStart", SIGNATURE_ON_START, activity);
if (onStart == null) return true;
PsiCodeBlock onStop = getMethodBodyByName("onStop", SIGNATURE_ON_STOP, activity);
if (onStop == null) return true;
List<PsiStatement> startStatements = StatementFilter.filterCodeBlock(FIREBASE_USER_ACTION_START, onStart);
if (startStatements.isEmpty()) return true;
List<PsiStatement> endStatements = StatementFilter.filterCodeBlock(FIREBASE_USER_ACTION_END, onStop);
if (endStatements.isEmpty()) return true;
return false;
}
/**
* Inserts the AppIndexing API code for the activity where the caret is in.
*/
public void insertAppIndexingApiCodeForActivity() {
if (myModule == null || myActivity == null || myFactory == null || myCodeStyleManager == null) {
Logger.getInstance(ApiCreator.class).info("Unable to generate App Indexing API code.");
return;
}
insertAppIndexingApiCodeInJavaFile(getDeepLinkOfActivity());
}
/**
* Generates App Indexing API code in Java source file.
* Steps:
* 1. Generates import statements in activity class.
* 2. Generates App Indexing API call in activity.
*
* @param deepLink The deep link for the activity. It may be nullable, because
* we should deal with situation where no deep link is specified
* in AndroidManifest.xml.
*/
@VisibleForTesting
void insertAppIndexingApiCodeInJavaFile(@Nullable String deepLink) {
insertImportStatements();
String getActionMethodCallText = insertGetActionMethod(deepLink);
addOrMergeOnStart(getActionMethodCallText);
addOrMergeOnStop(getActionMethodCallText);
JavaCodeStyleManager.getInstance(myProject).shortenClassReferences(myActivity);
unlockFromPsiOperation(myFile);
}
/**
* Insert a method which construct Action instance and return it. Return the name of the method inserted.
*
* @param deepLink The deeplink used to construct the Action instance.
* @return The name of the method inserted.
*/
@NotNull
private String insertGetActionMethod(@Nullable String deepLink) {
String methodName = getUnusedMethodName(METHOD_GET_ACTION);
String pageName = myActivity.getName();
pageName = pageName == null ? "" : StringUtil.trimEnd(pageName, "Activity");
String url = deepLink == null ? DEFAULT_DEEP_LINK_URL : deepLink;
String getActionMethod = String.format(
GET_ACTION_FORMAT, methodName, pageName, url, myImportClasses.get(CLASS_ACTION), myImportClasses.get(CLASS_BUILDERS_ACTIONS));
PsiMethod newGetAction = myFactory.createMethodFromText(getActionMethod, null);
myActivity.add(newGetAction);
return methodName;
}
@NotNull
private String getUnusedMethodName(@NotNull String methodName) {
PsiMethod[] psiMethods = myActivity.getMethods();
Set<String> usedMethodNames = Sets.newHashSet();
for (PsiMethod p : psiMethods) {
usedMethodNames.add(p.getName());
}
String unusedMethodName = methodName;
for (int suffix = 0; usedMethodNames.contains(unusedMethodName); suffix++) {
unusedMethodName = methodName + suffix;
}
return unusedMethodName;
}
/**
* Generates import statements of app indexing Java Code.
*/
private void insertImportStatements() {
// Without App Indexing dependency, App-Indexing-related classes cannot be found,
// so "shortenClassReferences" cannot be used.
for (Map.Entry<String, String> className : myImportClasses.entrySet()) {
if (!ImportUtils.hasOnDemandImportConflict(className.getValue(), myActivity) &&
!hasExactImportConflict(className.getValue(), (PsiJavaFile)myFile)) {
insertSingleImportIfNeeded(className.getValue());
className.setValue(className.getKey());
}
}
}
// Copied from ImportUtils - where it was made private in 1.5 so we can't call it directly.
private static boolean hasExactImportConflict(String fqName, PsiJavaFile file) {
final PsiImportList imports = file.getImportList();
if (imports == null) {
return false;
}
final PsiImportStatement[] importStatements = imports.getImportStatements();
final int lastDotIndex = fqName.lastIndexOf((int)'.');
final String shortName = fqName.substring(lastDotIndex + 1);
final String dottedShortName = '.' + shortName;
for (final PsiImportStatement importStatement : importStatements) {
if (importStatement.isOnDemand()) {
continue;
}
final String importName = importStatement.getQualifiedName();
if (importName == null) {
return false;
}
if (!importName.equals(fqName) && importName.endsWith(dottedShortName)) {
return true;
}
}
return false;
}
/**
* Generates app indexing api calls in onStart method.
*
* @param getActionMethodCallText The name of the method getAction.
*/
private void addOrMergeOnStart(@NotNull String getActionMethodCallText) {
if (myOnStart == null) {
String onStartMethod =
String.format(ON_START_FORMAT, myImportClasses.get(CLASS_FIREBASE_USER_ACTION), getActionMethodCallText);
myActivity.add(myFactory.createMethodFromText(onStartMethod, null));
return;
}
// AppIndex.AppIndexApi.start() at the bottom.
if (myStartStatements.isEmpty()) {
String startText =
String.format(FIREBASE_USER_ACTION_START_TEMPLATE, myImportClasses.get(CLASS_FIREBASE_USER_ACTION), getActionMethodCallText);
PsiElement startStatement = myFactory.createStatementFromText(startText, null);
startStatement = myOnStart.add(startStatement);
addCommentsBefore(COMMENT_IN_JAVA, myOnStart, startStatement);
}
}
/**
* Generates app indexing api calls in onStop method.
*
* @param getActionMethodCallText The name of the method getAction.
*/
private void addOrMergeOnStop(@NotNull String getActionMethodCallText) {
if (myOnStop == null) {
String onStopMethod = String.format(ON_STOP_FORMAT, myImportClasses.get(CLASS_FIREBASE_USER_ACTION), getActionMethodCallText);
myActivity.add(myFactory.createMethodFromText(onStopMethod, null));
return;
}
if (!myEndStatements.isEmpty()) {
return;
}
// Creates Action variable and AppIndex.AppIndexApi.end() on the top.
PsiStatement endStatement = myFactory.createStatementFromText(
String.format(FIREBASE_USER_ACTION_END_TEMPLATE, myImportClasses.get(CLASS_FIREBASE_USER_ACTION), getActionMethodCallText), null);
List<PsiStatement> superOnStopStatements = StatementFilter.filterCodeBlock("super.onStop();", myOnStop);
if (!superOnStopStatements.isEmpty()) {
// after "super.onStop();"
endStatement = (PsiStatement)myOnStop.addAfter(endStatement, superOnStopStatements.get(0));
addCommentsBefore(COMMENT_IN_JAVA, myOnStop, endStatement);
}
else {
// after "{" in the code block.
endStatement = (PsiStatement)myOnStop.addAfter(endStatement, myOnStop.getFirstBodyElement());
addCommentsBefore(COMMENT_IN_JAVA, myOnStop, endStatement);
}
}
/**
* Returns deep links of current activity in AndroidManifest.xml.
*/
@Nullable
@VisibleForTesting
String getDeepLinkOfActivity() {
XmlFile manifest = ManifestUtils.getAndroidManifestPsi(myModule);
if (manifest != null && manifest.getRootTag() != null) {
List<XmlTag> activityTags = ManifestUtils.searchXmlTagsByName(manifest.getRootTag(), SdkConstants.TAG_ACTIVITY);
for (XmlTag activityTag : activityTags) {
String activityName = activityTag.getAttributeValue(SdkConstants.ATTR_NAME, SdkConstants.ANDROID_URI);
if (activityName != null && activityName.equals("." + myActivity.getName())) {
List<String> deepLinks = DeepLinkUtils.getAllDeepLinks(activityTag);
if (!deepLinks.isEmpty()) {
return deepLinks.get(0);
}
}
}
}
return null;
}
/**
* Adds comments before a specific psi element.
*
* @param texts The text of the comments.
* "// Comment1.
* // Comment2." are 2 comments.
* Factory cannot create 2 comments together, so we split them into list of comment texts.
* @param element The psi element for adding comment in.
* @param anchor The psi element the added comment is anchored before.
*/
private void addCommentsBefore(@NotNull List<String> texts, @NotNull PsiElement element, @NotNull PsiElement anchor) {
myCodeStyleManager.reformat(element);
for (String text : texts) {
element.addBefore(myFactory.createCommentFromText(text, null), anchor);
myCodeStyleManager.reformat(element);
}
}
/**
* Unlocks the file locked by PSI operation, so that the project can sync.
*
* @param file The file locked by PSI operation.
*/
private void unlockFromPsiOperation(@NotNull PsiFile file) {
Document doc = PsiDocumentManager.getInstance(myProject).getDocument(file);
if (doc != null) {
PsiDocumentManager.getInstance(myProject).doPostponedOperationsAndUnblockDocument(doc);
}
}
/**
* Gets the surrounding class, if it inherits from a particular super class.
*
* @param element The PsiElement for query.
* @param inheritedClass the name of the class to inherit from
* @return The surrounding class that meet the inheriting requirement.
*/
@Nullable
private static PsiClass getSurroundingInheritingClass(@NotNull PsiElement element, @NotNull String inheritedClass) {
while (element != null) {
if (element instanceof PsiClass) {
PsiClass psiClass = (PsiClass)element;
if (InheritanceUtil.isInheritor(psiClass, inheritedClass)) {
return psiClass;
}
}
if (element instanceof PsiFile) {
return null;
}
element = element.getParent();
}
return null;
}
/**
* Gets the body of the method in a specific class, given the method name.
*
* @param name The method name.
* @param parametersTypeName The type name of the parameters in the specific method.
* @param psiClass The class to find method in.
* @return The code block body of the method, which will be null if no method is found with designated name or parameters.
*/
@Nullable
public static PsiCodeBlock getMethodBodyByName(@NotNull String name,
@NotNull List<String> parametersTypeName,
@NotNull PsiClass psiClass) {
PsiMethod[] psiMethods = psiClass.findMethodsByName(name, false);
for (PsiMethod psiMethod : psiMethods) {
PsiType[] types = psiMethod.getSignature(PsiSubstitutor.EMPTY).getParameterTypes();
if (types.length != parametersTypeName.size()) {
continue;
}
boolean correctSignature = true;
for (int i = 0; i < types.length; i++) {
if (!types[i].getCanonicalText().equals(parametersTypeName.get(i))) {
correctSignature = false;
break;
}
}
if (correctSignature) {
return psiMethod.getBody();
}
}
return null;
}
/**
* Inserts a single import statement.
*
* @param className The name of the import class.
*/
private void insertSingleImportIfNeeded(@NotNull String className) {
PsiImportList importList = ((PsiJavaFile)myFile).getImportList();
if (importList != null && !hasImportStatement(importList, className)) {
String dummyFileName = "_Dummy_" + className + "_." + JavaFileType.INSTANCE.getDefaultExtension();
PsiJavaFile aFile = (PsiJavaFile)PsiFileFactory.getInstance(myProject)
.createFileFromText(dummyFileName, JavaFileType.INSTANCE, "import " + className + ";");
PsiImportList dummyImportList = aFile.getImportList();
if (dummyImportList != null) {
PsiImportStatement[] statements = dummyImportList.getImportStatements();
PsiImportStatement statement = (PsiImportStatement)myCodeStyleManager.reformat(statements[0]);
importList.add(statement);
}
}
}
/**
* If an class has been imported in the importList.
*
* @param importList The import list.
* @param className The name of the class to be imported.
* @return True if an class has been imported.
*/
public static boolean hasImportStatement(@NotNull PsiImportList importList, @NotNull String className) {
String packageName = className.substring(0, className.lastIndexOf('.'));
PsiImportStatement singleImport = importList.findSingleClassImportStatement(className);
PsiImportStatement onDemandImport = importList.findOnDemandImportStatement(packageName);
return singleImport != null || onDemandImport != null;
}
/**
* Check if the existing dependencies are enough to insert App Indexing code.
*
* @return true if there are enough dependencies.
*/
// TODO: Consider to replace this method with DependencyStateManager.getPendingDependencies method, once it's refactored out from firebase module.
static boolean hasEnoughDependencies(@NotNull Module module) {
final GradleBuildModel gradleBuildModel = GradleBuildModel.get(module);
// Possibly null in scenario such as gradle sync in progress.
if (gradleBuildModel == null) {
return false;
}
DependenciesModel dependenciesModel = gradleBuildModel.dependencies();
if (dependenciesModel == null) {
return false;
}
if (!hasDependency(APP_INDEXING_LIB_DEPENDENCY, dependenciesModel.artifacts())) {
return false;
}
// Check the plugin dependency.
final List<String> plugins = getValues(gradleBuildModel.appliedPlugins());
if (!plugins.contains(APP_INDEXING_PLUGIN_DEPENDENCY)) {
return false;
}
// Check classpath dependency at the project level.
final GradleBuildModel projectGradleBuildModel = GradleBuildModel.get(module.getProject());
if (projectGradleBuildModel == null) {
return false;
}
final DependenciesModel dependencies = projectGradleBuildModel.buildscript().dependencies();
return hasDependency(APP_INDEXING_CLASSPATH_DEPENDENCY, dependencies.artifacts(CommonConfigurationNames.CLASSPATH));
}
/**
* Check if the designated dependency already exists
*
* @param dependencyValue module or classpath dependency value with version.
* @param existingDeps current existing dependencies.
* @return true if the designated dependency already exists.
*/
@NotNull
private static boolean hasDependency(@NotNull String dependencyValue,
@NotNull List<ArtifactDependencyModel> existingDeps) {
ArtifactDependencySpec requiredDepSpec = ArtifactDependencySpec.create(dependencyValue);
if (requiredDepSpec == null) {
return true;
}
for (ArtifactDependencyModel dependency : existingDeps) {
ArtifactDependencySpec foundDepSpec = ArtifactDependencySpec.create(dependency);
if (foundDepSpec.equalsIgnoreVersion(requiredDepSpec) &&
VersionComparatorUtil.compare(foundDepSpec.version, requiredDepSpec.version) >= 0) {
return true;
}
}
return false;
}
}