blob: 24127990fcf5b15696ebdde480c830b0cd300adf [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.builder.model.AndroidArtifact;
import com.android.builder.model.AndroidLibrary;
import com.android.builder.model.Dependencies;
import com.android.builder.model.MavenCoordinates;
import com.android.ide.common.repository.GradleCoordinate;
import com.android.tools.idea.gradle.project.model.AndroidModuleModel;
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.DependenciesModel;
import com.android.tools.idea.gradle.project.sync.GradleSyncInvoker;
import com.google.appindexing.util.DeepLinkUtils;
import com.google.appindexing.util.ManifestUtils;
import com.google.common.base.CharMatcher;
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.lang.xml.XMLLanguage;
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.psi.*;
import com.intellij.psi.codeStyle.CodeStyleManager;
import com.intellij.psi.codeStyle.JavaCodeStyleManager;
import com.intellij.psi.codeStyle.VariableKind;
import com.intellij.psi.util.InheritanceUtil;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.psi.xml.XmlComment;
import com.intellij.psi.xml.XmlFile;
import com.intellij.psi.xml.XmlTag;
import com.siyeh.ig.psiutils.ImportUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.android.tools.idea.gradle.dsl.model.dependencies.CommonConfigurationNames.COMPILE;
import static com.android.tools.idea.gradle.util.GradleUtil.getDependencies;
/**
* 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 COMPILE_GMS_GROUP = "com.google.android.gms";
static private final String COMPILE_APPINDEXING = "play-services-appindexing";
static private final String MINIMUM_VERSION = "8.4.0";
static private final String TAG_METADATA = "meta-data";
static private final String ATTR_NAME_METADATA_GMS = "com.google.android.gms.version";
static private final String ATTR_VALUE_METADATA_GMS = "@integer/google_play_services_version";
static private final String METHOD_GET_ACTION = "getIndexApiAction";
static private final String CLASS_ACTION = "Action";
static private final String CLASS_APP_INDEX = "AppIndex";
static private final String CLASS_GOOGLE_API_CLIENT = "GoogleApiClient";
static private final String CLASS_THING = "Thing";
static private final String CLASS_ACTION_FULL = "com.google.android.gms.appindexing.Action";
static private final String CLASS_APP_INDEX_FULL = "com.google.android.gms.appindexing.AppIndex";
static private final String CLASS_THING_FULL = "com.google.android.gms.appindexing.Thing";
static private final String CLASS_GOOGLE_API_CLIENT_FULL = "com.google.android.gms.common.api.GoogleApiClient";
static private final List<String> SIGNATURE_ON_CREATE = Lists.newArrayList("android.os.Bundle");
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_CREATE_FORMAT = "@Override\n" +
"protected void onCreate(android.os.Bundle savedInstanceState) {\n" +
" super.onCreate(savedInstanceState);\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 = new %2$s.Builder(this).addApi(%3$s.API).build();\n" +
"}";
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.connect();\n" +
" %3$s.AppIndexApi.start(%1$s, %2$s);\n" +
"}";
static private final String ON_STOP_FORMAT = "@Override\n" +
"public void onStop() {\n" +
" super.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" +
" %3$s.AppIndexApi.end(%1$s, %2$s);\n" +
" %1$s.disconnect();\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" +
" %5$s object = new %5$s.Builder()\n" +
" .setName(\"%2$s Page\") // TODO: Define a title for the content shown.\n" +
" // TODO: Make sure this auto-generated URL is correct.\n" +
" .setUrl(android.net.Uri.parse(\"%3$s\"))\n" +
" .build();\n" +
" return new %4$s.Builder(%4$s.TYPE_VIEW)\n" +
" .setObject(object)\n" +
" .setActionStatus(%4$s.STATUS_TYPE_COMPLETED)\n" +
" .build();\n" +
"}";
static private final String CLIENT_FORMAT = "%1$s = new %2$s.Builder(this).addApi(%3$s.API).build();";
static private final String BUILDER_APPINDEXAPI = ".addApi(%s.API)";
static private final String APP_INDEXING_START = "%3$s.AppIndexApi.start(%1$s,%2$s);";
static private final String APP_INDEXING_END = "%3$s.AppIndexApi.end(%1$s,%2$s);";
static private final String APP_INDEXING_START_HALF = "AppIndexApi.start(%1$s,";
static private final String APP_INDEXING_END_HALF = "AppIndexApi.end(%1$s,";
static private final String APP_INDEXING_VIEW = "AppIndexApi.view(%1$s,";
static private final String APP_INDEXING_VIEWEND = "AppIndexApi.viewEnd(%1$s,";
static private final List<String> COMMENT_BUILDER_APPINDEXAPI = Lists
.newArrayList("// ATTENTION: This \"addApi(AppIndex.API)\"was auto-generated to implement the App Indexing API.",
"// See https://g.co/AppIndexing/AndroidStudio for more information.");
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.");
static private final String COMMENT_FOR_FIELD = "/**\n" +
" * ATTENTION: This %1$swas auto-generated to implement the App Indexing API.\n" +
" * See https://g.co/AppIndexing/AndroidStudio for more information.\n" +
" */";
static private final String COMMENT_IN_MANIFEST =
"<!-- ATTENTION: This was auto-generated to add Google Play services to your project for\n" +
" App Indexing. 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]";
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 myOnCreate = 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 List<PsiStatement> myViewStatements = Lists.newArrayList();
private List<PsiStatement> myViewEndStatements = Lists.newArrayList();
private String myHighestGmsLibVersion = null;
private String myAppIndexingLibVersion = null;
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) {
myOnCreate = getMethodBodyByName("onCreate", SIGNATURE_ON_CREATE, myActivity);
myOnStart = getMethodBodyByName("onStart", SIGNATURE_ON_START, myActivity);
myOnStop = getMethodBodyByName("onStop", SIGNATURE_ON_STOP, myActivity);
if (myOnStart != null) {
myStartStatements = StatementFilter.filterCodeBlock("AppIndex.AppIndexApi.start", myOnStart);
myViewStatements = StatementFilter.filterCodeBlock("AppIndex.AppIndexApi.view", myOnStart);
}
if (myOnStop != null) {
myEndStatements = StatementFilter.filterCodeBlock("AppIndex.AppIndexApi.end", myOnStop);
myViewEndStatements = StatementFilter.filterCodeBlock("AppIndex.AppIndexApi.viewEnd", myOnStop);
}
}
}
}
myFactory = (PsiElementFactory)JVMElementFactories.getFactory(JavaLanguage.INSTANCE, myProject);
myCodeStyleManager = CodeStyleManager.getInstance(myProject);
myImportClasses.put(CLASS_ACTION, CLASS_ACTION_FULL);
myImportClasses.put(CLASS_APP_INDEX, CLASS_APP_INDEX_FULL);
myImportClasses.put(CLASS_GOOGLE_API_CLIENT, CLASS_GOOGLE_API_CLIENT_FULL);
myImportClasses.put(CLASS_THING, CLASS_THING_FULL);
}
@VisibleForTesting
void setGmsLibVersion(@Nullable String version) {
myHighestGmsLibVersion = version;
myAppIndexingLibVersion = version;
}
/**
* 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;
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("AppIndexApi.start", onStart);
List<PsiStatement> viewStatements = StatementFilter.filterCodeBlock("AppIndexApi.view", onStart);
if (startStatements.isEmpty() && viewStatements.isEmpty()) return true;
List<PsiStatement> endStatements = StatementFilter.filterCodeBlock("AppIndexApi.end", onStop);
List<PsiStatement> viewEndStatements = StatementFilter.filterCodeBlock("AppIndexApi.viewEnd", onStop);
if (endStatements.isEmpty() && viewEndStatements.isEmpty()) return true;
return false;
}
/**
* Inserts the AppIndexing API code for the activity where the caret is in.
* Steps:
* 1. Generates Google Play Service Support in build.gradle and AndroidManifest.xml of current
* module.
* 2. Generates App Indexing API code in Java source file.
* 3. Sync the project if build.gradle of module is changed.
*/
public void insertAppIndexingApiCodeForActivity() {
boolean needGradleSync = false;
try {
if (myModule == null || myActivity == null || myFactory == null || myCodeStyleManager == null) {
Logger.getInstance(ApiCreator.class).info("Unable to generate App Indexing API code.");
return;
}
getGmsDependencyVersion();
needGradleSync = insertGmsCompileDependencyInGradleIfNeeded();
XmlFile manifestPsiFile = ManifestUtils.getAndroidManifestPsi(myModule);
if (manifestPsiFile == null) {
Logger.getInstance(ApiCreator.class).info("AndroidManifest.xml not found.");
return;
}
insertGmsVersionTagInManifestIfNeeded(manifestPsiFile);
insertAppIndexingApiCodeInJavaFile(getDeepLinkOfActivity(), !needGradleSync);
}
catch (ApiCreatorException e) {
e.printStackTrace();
}
finally {
if (needGradleSync) {
GradleSyncInvoker.getInstance().requestProjectSyncAndSourceGeneration(myProject, null);
}
}
}
private void getGmsDependencyVersion() throws ApiCreatorException {
AndroidModuleModel model = AndroidModuleModel.get(myModule);
if (model == null) {
throw new ApiCreatorException("AndroidGradleModel not found.");
}
AndroidArtifact artifact = model.getMainArtifact();
Dependencies dependencies = getDependencies(artifact, model.getModelVersion());
Collection<AndroidLibrary> libraries = dependencies.getLibraries();
for (AndroidLibrary library : libraries) {
getDependencyVersionFromAndroidLibrary(library);
}
}
private void getDependencyVersionFromAndroidLibrary(AndroidLibrary library) {
MavenCoordinates coordinates = library.getResolvedCoordinates();
if (coordinates != null && coordinates.getGroupId().equals(COMPILE_GMS_GROUP)) {
String version = coordinates.getVersion();
if (coordinates.getArtifactId().equals(COMPILE_APPINDEXING)) {
myAppIndexingLibVersion = version;
}
if (myHighestGmsLibVersion == null || compareVersion(version, myHighestGmsLibVersion) > 0) {
myHighestGmsLibVersion = version;
}
}
for (AndroidLibrary dependency : library.getLibraryDependencies()) {
getDependencyVersionFromAndroidLibrary(dependency);
}
}
/**
* Generates App Indexing Support in build.gradle of app module, if no such support exists.
*
* @return true if gradle myFile is changed and needs sync.
*/
@VisibleForTesting
boolean insertGmsCompileDependencyInGradleIfNeeded() throws ApiCreatorException {
GradleBuildModel buildModel = GradleBuildModel.get(myModule);
if (buildModel == null) {
throw new ApiCreatorException("Build model not found.");
}
DependenciesModel dependencies = buildModel.dependencies();
String versionToUse = MINIMUM_VERSION;
if (myHighestGmsLibVersion != null && compareVersion(myHighestGmsLibVersion, MINIMUM_VERSION) > 0) {
versionToUse = myHighestGmsLibVersion;
}
boolean gradleChange = false;
if (myAppIndexingLibVersion == null) {
ArtifactDependencySpec newDependency = new ArtifactDependencySpec(COMPILE_APPINDEXING, COMPILE_GMS_GROUP, versionToUse);
dependencies = buildModel.dependencies();
dependencies.addArtifact(COMPILE, newDependency);
buildModel.applyChanges();
gradleChange = true;
}
if (myHighestGmsLibVersion != null && compareVersion(myHighestGmsLibVersion, versionToUse) < 0) {
List<ArtifactDependencyModel> dependencyList = dependencies.artifacts();
for (ArtifactDependencyModel dependency : dependencyList) {
String group = dependency.group().value();
if (group != null && group.equals(COMPILE_GMS_GROUP)) {
dependency.setVersion(versionToUse);
buildModel.applyChanges();
gradleChange = true;
}
}
}
return gradleChange;
}
/**
* Generates App Indexing Support in AndroidManifest.xml.
*
* @param manifestPsiFile The psi file of AndroidManifest.xml.
*/
@VisibleForTesting
void insertGmsVersionTagInManifestIfNeeded(@NotNull XmlFile manifestPsiFile) {
XmlTag root = manifestPsiFile.getRootTag();
if (root != null) {
List<XmlTag> applications = ManifestUtils.searchXmlTagsByName(root, SdkConstants.TAG_APPLICATION);
for (XmlTag application : applications) {
if (getGmsTag(application) == null) {
XmlTag gms = application.createChildTag(TAG_METADATA, null, null, false);
gms = application.addSubTag(gms, false);
gms.setAttribute(SdkConstants.ATTR_NAME, SdkConstants.ANDROID_URI, ATTR_NAME_METADATA_GMS);
gms.setAttribute(SdkConstants.ATTR_VALUE, SdkConstants.ANDROID_URI, ATTR_VALUE_METADATA_GMS);
XmlTag GmsTag = getGmsTag(application);
XmlComment comment = createXmlComment(COMMENT_IN_MANIFEST);
if (comment != null && GmsTag != null) {
application.addBefore(comment, GmsTag);
}
}
}
unlockFromPsiOperation(manifestPsiFile);
}
}
/**
* Generates App Indexing API code in Java source file.
* Steps:
* 1. Generates import statements in activity class.
* 2. Generates GoogleApiClient variable.
* The method will firstly scan the code to find existing App Indexing API call to get
* GoogleApiClient candidate. Then verify if the candidate could be reused in the generated
* code - it's a class member and the initialization can be adjusted to add App Indexing API.
* If not, a new GoogleApiClient class member will be created.
* 3. 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.
* @param withAppIndexingDependency If gradle will be sync later.
*/
@VisibleForTesting
void insertAppIndexingApiCodeInJavaFile(@Nullable String deepLink, boolean withAppIndexingDependency) {
insertImportStatements(withAppIndexingDependency);
String clientName = null, clientNameCandidate = null;
List<PsiStatement> statementsAppIndexApi = getAppIndexApiStatement();
for (PsiStatement statementAppIndexApi : statementsAppIndexApi) {
clientNameCandidate = getClientInAppIndexingApi(statementAppIndexApi);
if (clientNameCandidate != null) {
break;
}
}
if (clientNameCandidate == null) {
List<String> clientNames = getFieldNameByType(CLASS_GOOGLE_API_CLIENT);
if (!clientNames.isEmpty()) {
clientNameCandidate = clientNames.get(0);
}
}
if (clientNameCandidate != null) {
PsiField clientField = getFieldByName(clientNameCandidate);
PsiStatement clientInitStatement = null;
if (clientField != null && !clientField.hasInitializer()) {
clientInitStatement = getClientInitStatements(clientNameCandidate);
}
// Adjusts client's initialization to add AppIndexing API.
if (clientField != null) {
boolean adjustmentSucceed = false;
if (clientField.hasInitializer()) {
adjustmentSucceed = adjustClientFieldInitializerIfNeeded(clientField);
}
else if (clientInitStatement != null) {
adjustmentSucceed = adjustClientInitStatementIfNeeded(clientInitStatement);
}
if (adjustmentSucceed) {
clientName = clientNameCandidate;
}
}
}
if (clientName == null) {
// Creates a class member with GoogleApiClient type, and initializes it at onCreate method.
clientName = createGoogleApiClientField();
}
String getActionMethodCallText = insertGetActionMethod(deepLink) + "()";
addOrMergeOnStart(clientName, getActionMethodCallText);
addOrMergeOnStop(clientName, 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();
if (pageName.endsWith("Activity")) {
pageName = pageName.substring(0, pageName.length() - 8);
}
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_THING));
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.
*
* @param withAppIndexingDependency If gradle will be sync later.
*/
private void insertImportStatements(boolean withAppIndexingDependency) {
if (!withAppIndexingDependency) {
// 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;
}
/**
* Searches App Indexing API call in onStart & onStop.
*
* @return All the App Indexing API call found.
*/
@NotNull
private List<PsiStatement> getAppIndexApiStatement() {
List<PsiStatement> result = Lists.newArrayList(myStartStatements);
result.addAll(myViewStatements);
if (!result.isEmpty()) {
return result;
}
result.addAll(myEndStatements);
result.addAll(myViewEndStatements);
return result;
}
/**
* Gets all assignment statements of the client in onCreate.
*
* @param clientName The GoogleApiClient name.
* @return All the init statements.
*/
@Nullable
private PsiStatement getClientInitStatements(@NotNull String clientName) {
if (myOnCreate != null) {
List<PsiStatement> statements = StatementFilter.filterCodeBlock(".build()", myOnCreate);
for (PsiStatement statement : statements) {
if (statement instanceof PsiExpressionStatement) {
PsiType statementType = ((PsiExpressionStatement)statement).getExpression().getType();
if (statementType != null &&
statementType.getPresentableText().equals(CLASS_GOOGLE_API_CLIENT) &&
CharMatcher.WHITESPACE.removeFrom(statement.getText()).startsWith(clientName + "=")) {
return statement;
}
}
}
}
return null;
}
/**
* Adjusts the initializer of the client field to add App Indexing API,
* if there is the initializer ends with ".build()" and have not added App Indexing API.
*
* @param clientField The client field.
* @return true if the adjustment succeeds, i.e. the initializer has added App Indexing API.
*/
private boolean adjustClientFieldInitializerIfNeeded(@NotNull PsiField clientField) {
PsiExpression clientInitializer = clientField.getInitializer();
if (clientInitializer != null) {
String oldInitializerText = clientInitializer.getText();
String newInitializerText = generateApiClientInitializeString(oldInitializerText);
if (newInitializerText != null) {
if (!newInitializerText.equals(oldInitializerText)) {
clientField.setInitializer(myFactory.createExpressionFromText(newInitializerText, null));
String commentText = String.format(COMMENT_FOR_FIELD, "\"addApi(AppIndex.API)\" ");
myActivity.addBefore(myFactory.createCommentFromText(commentText, null), clientField);
}
return true;
}
}
return false;
}
/**
* Adjusts the the client initializing statement to add AppIndexing API,
* if there is the initializer ends with ".build()" and have not added App Indexing API.
*
* @param statement The init statement of client.
* @return true if the adjustment succeeds, i.e. the statement has added App Indexing API.
*/
private boolean adjustClientInitStatementIfNeeded(@NotNull PsiStatement statement) {
String oldText = statement.getText();
String newText = generateApiClientInitializeString(oldText);
if (newText != null) {
PsiElement newStatement = statement.replace(myFactory.createStatementFromText(newText, null));
if (!newText.equals(oldText)) {
addCommentsBefore(COMMENT_BUILDER_APPINDEXAPI, myOnCreate, newStatement);
}
return true;
}
return false;
}
/**
* Creates a class member with GoogleApiClient type, and initializes it at onCreate method.
*
* @return The GoogleApiClient name.
*/
@NotNull
private String createGoogleApiClientField() {
String clientName = getUnusedName("client", VariableKind.FIELD);
PsiClassType type = myFactory.createTypeByFQClassName(myImportClasses.get(CLASS_GOOGLE_API_CLIENT));
PsiField clientField = (PsiField)myActivity.add(myFactory.createField(clientName, type));
myCodeStyleManager.reformat(myActivity);
String comment = String.format(COMMENT_FOR_FIELD, "");
myActivity.addBefore(myFactory.createCommentFromText(comment, null), clientField);
generateGoogleApiClientInitializationInOnCreate(clientName);
return clientName;
}
/**
* Generates GoogleApiClient initialization in onCreate method.
* Used when there is no initializing statement before.
*
* @param clientName The GoogleApiClient name.
*/
private void generateGoogleApiClientInitializationInOnCreate(@NotNull String clientName) {
if (myOnCreate == null) {
String onCreateMethod =
String.format(ON_CREATE_FORMAT, clientName, myImportClasses.get(CLASS_GOOGLE_API_CLIENT), myImportClasses.get(CLASS_APP_INDEX));
PsiMethod newOnCreate = myFactory.createMethodFromText(onCreateMethod, null);
myActivity.add(newOnCreate);
}
else {
String initText =
String.format(CLIENT_FORMAT, clientName, myImportClasses.get(CLASS_GOOGLE_API_CLIENT), myImportClasses.get(CLASS_APP_INDEX));
PsiElement initStatement = myOnCreate.add(myFactory.createStatementFromText(initText, null));
addCommentsBefore(COMMENT_IN_JAVA, myOnCreate, initStatement);
}
}
/**
* Generates app indexing api calls (GoogleApiClient's connect & start) in onStart method.
*
* @param clientName The GoogleApiClient name.
* @param getActionMethodCallText The text of method call for get action.
*/
private void addOrMergeOnStart(@NotNull String clientName, @NotNull String getActionMethodCallText) {
if (myOnStart == null) {
String onStartMethod = String.format(ON_START_FORMAT, clientName, getActionMethodCallText, myImportClasses.get(CLASS_APP_INDEX));
myActivity.add(myFactory.createMethodFromText(onStartMethod, null));
}
else {
String connectCall = clientName + ".connect();";
List<PsiStatement> connectStatements = StatementFilter.filterCodeBlock(connectCall, myOnStart);
// Creates connect() if needed. It should be on the top.
if (connectStatements.isEmpty()) {
PsiStatement connectStatement = myFactory.createStatementFromText(connectCall, null);
List<PsiStatement> superOnStartStatements = StatementFilter.filterCodeBlock("super.onStart();", myOnStart);
if (!superOnStartStatements.isEmpty()) {
// Adds connect() statement after "super.onStart();".
connectStatement = (PsiStatement)myOnStart.addAfter(connectStatement, superOnStartStatements.get(0));
addCommentsBefore(COMMENT_IN_JAVA, myOnStart, connectStatement);
}
else {
// Adds connect() statement after "{" in the code block.
connectStatement = (PsiStatement)myOnStart.addAfter(connectStatement, myOnStart.getFirstBodyElement());
addCommentsBefore(COMMENT_IN_JAVA, myOnStart, connectStatement);
}
}
String startTextHalf = String.format(APP_INDEXING_START_HALF, clientName);
myStartStatements = StatementFilter.filterStatements(startTextHalf, myStartStatements);
String viewText = String.format(APP_INDEXING_VIEW, clientName);
myViewStatements = StatementFilter.filterStatements(viewText, myViewStatements);
// Creates Action variable and AppIndex.AppIndexApi.start() at the bottom.
if (myStartStatements.isEmpty() && myViewStatements.isEmpty()) {
String startText = String.format(APP_INDEXING_START, clientName, getActionMethodCallText, myImportClasses.get(CLASS_APP_INDEX));
PsiElement startStatement = myFactory.createStatementFromText(startText, null);
startStatement = myOnStart.add(startStatement);
addCommentsBefore(COMMENT_IN_JAVA, myOnStart, startStatement);
}
}
}
/**
* Generates app indexing api calls (GoogleApiClient's disconnect & end) in onStop method.
*
* @param clientName The GoogleApiClient name.
* @param getActionMethodCallText The text of method call for get action.
*/
private void addOrMergeOnStop(@NotNull String clientName, @NotNull String getActionMethodCallText) {
if (myOnStop == null) {
String onStopMethod = String.format(ON_STOP_FORMAT, clientName, getActionMethodCallText, myImportClasses.get(CLASS_APP_INDEX));
myActivity.add(myFactory.createMethodFromText(onStopMethod, null));
}
else {
String disconnectCall = clientName + ".disconnect();";
List<PsiStatement> disconnectStatements = StatementFilter.filterCodeBlock(disconnectCall, myOnStop);
// Creates disconnect() if needed. It should be at the bottom.
if (disconnectStatements.isEmpty()) {
PsiElement disconnectStatement = myOnStop.add(myFactory.createStatementFromText(disconnectCall, null));
addCommentsBefore(COMMENT_IN_JAVA, myOnStop, disconnectStatement);
}
String endTextHalf = String.format(APP_INDEXING_END_HALF, clientName);
myEndStatements = StatementFilter.filterStatements(endTextHalf, myEndStatements);
String viewEndText = String.format(APP_INDEXING_VIEWEND, clientName);
myViewEndStatements = StatementFilter.filterStatements(viewEndText, myViewEndStatements);
// Creates Action variable and AppIndex.AppIndexApi.end() on the top.
if (myEndStatements.isEmpty() && myViewEndStatements.isEmpty()) {
String endText = String.format(APP_INDEXING_END, clientName, getActionMethodCallText, myImportClasses.get(CLASS_APP_INDEX));
PsiStatement endStatement = myFactory.createStatementFromText(endText, 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);
}
}
}
}
/**
* Compares two versions in the form of "12.3.456" etc.
*
* @return < 0 if version1 < version2; = 0 if version1 = version2; > 0 if version1 > version2
*/
private static int compareVersion(@NotNull String version1, @NotNull String version2) {
GradleCoordinate coordinate1 = GradleCoordinate.parseVersionOnly(version1);
GradleCoordinate coordinate2 = GradleCoordinate.parseVersionOnly(version2);
return GradleCoordinate.COMPARE_PLUS_HIGHER.compare(coordinate1, coordinate2);
}
/**
* Gets the GMS meta-data tag in the application tag.
*/
@Nullable
private static XmlTag getGmsTag(@NotNull XmlTag application) {
XmlTag[] children = application.getSubTags();
for (XmlTag child : children) {
if (child.getName().equalsIgnoreCase(TAG_METADATA)) {
String tagName = child.getAttributeValue(SdkConstants.ATTR_NAME, SdkConstants.ANDROID_URI);
if (tagName != null && tagName.equals(ATTR_NAME_METADATA_GMS)) {
return child;
}
}
}
return null;
}
/**
* Gets the client name in an app indexing API call statement.
*/
@Nullable
private static String getClientInAppIndexingApi(@NotNull PsiStatement statement) {
if (statement instanceof PsiExpressionStatement) {
// In the form of "AppIndex.AppIndexApi.start(mClient, viewAction);".
PsiElement[] children = statement.getChildren();
for (PsiElement child : children) {
if (child instanceof PsiMethodCallExpression) {
String callExpression = ((PsiMethodCallExpression)child).getMethodExpression().getText();
callExpression = CharMatcher.WHITESPACE.removeFrom(callExpression);
PsiType[] argsType = ((PsiMethodCallExpression)child).getArgumentList().getExpressionTypes();
// API start / end has 2 arguments, viewEnd has 3 arguments, and view has 2.
// The first arg is GoogleApiClient. If not declared before used, it has a null type.
if ((callExpression.startsWith("AppIndex.AppIndexApi.") ||
callExpression.startsWith("AppIndexApi") ||
callExpression.startsWith(CLASS_APP_INDEX_FULL)) &&
(argsType.length == 2 || argsType.length == 3 || argsType.length == 6) &&
argsType[0] != null) {
PsiExpression[] args = ((PsiMethodCallExpression)child).getArgumentList().getExpressions();
String clientName = args[0].getText();
if (clientName != null) {
return clientName;
}
}
}
}
}
else if (statement instanceof PsiDeclarationStatement) {
// In form of "PendingResult<Status> a = AppIndex.AppIndexApi.start(mClient, viewAction);".
PsiElement[] children = ((PsiDeclarationStatement)statement).getDeclaredElements();
for (PsiElement child : children) {
if (child instanceof PsiVariable) {
PsiExpression initializer = ((PsiVariable)child).getInitializer();
if (initializer != null) {
// Initializer is in the form of "AppIndex.AppIndexApi.start(mClient, viewAction)".
PsiElement[] initChildren = initializer.getChildren();
if (initChildren.length == 2 &&
initChildren[0] instanceof PsiReferenceExpression &&
initChildren[1] instanceof PsiExpressionList) {
// initChildren[0] should be in the form of "AppIndex.AppIndexApi.start".
// initChildren[1] should be in the form of "(mClient, ...)".
// args should be in the form of {"mClient", ...}. Check its length and type.
String referenceExpression = CharMatcher.WHITESPACE.removeFrom(initChildren[0].getText());
PsiExpression[] args = ((PsiExpressionList)initChildren[1]).getExpressions();
// API start / end has 2 arguments, viewEnd has 3 arguments, and view has 2.
// The first arg is GoogleApiClient. If not declared before used, it has a null type.
if ((referenceExpression.startsWith("AppIndex.AppIndexApi.") ||
referenceExpression.startsWith("AppIndexApi.") ||
referenceExpression.startsWith(CLASS_APP_INDEX_FULL)) &&
(args.length == 2 || args.length == 3 || args.length == 6) && args[0].getType() != null) {
String clientName = args[0].getText();
if (clientName != null) {
return clientName;
}
}
}
}
}
}
}
return null;
}
/**
* Generates a name which has not been used.
* Example: if "name" is used, "name2" will be returned;
* if "name2" is also used, "name3" will be returned; ...
*
* @param name The initial default name.
* @return A name based on the initial name which has not been used.
*/
@NotNull
private String getUnusedName(String name, VariableKind kind) {
JavaCodeStyleManager javaCodeStyleManager = JavaCodeStyleManager.getInstance(myProject);
name = javaCodeStyleManager.suggestVariableName(kind, name, null, null).names[0];
Set<String> usedName = getUsedVariableName();
String unusedName = name;
int suffix = 2;
while (usedName.contains(unusedName)) {
unusedName = name + suffix;
suffix++;
}
return unusedName;
}
/**
* Generates GoogleApiClient initializing string.
*
* @param statementText The original initialize string.
* @return The new string.
*/
@Nullable
private String generateApiClientInitializeString(@NotNull String statementText) {
int splitPoint = statementText.lastIndexOf(".build()");
if (!statementText.contains(String.format(BUILDER_APPINDEXAPI, CLASS_APP_INDEX_FULL)) &&
!statementText.contains(String.format(BUILDER_APPINDEXAPI, CLASS_APP_INDEX)) && splitPoint != -1) {
return statementText.substring(0, splitPoint) +
String.format(BUILDER_APPINDEXAPI, myImportClasses.get(CLASS_APP_INDEX)) +
statementText.substring(splitPoint);
}
else if (splitPoint != -1) {
return statementText;
}
return null;
}
/**
* 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;
}
/**
* Creates xml comment element.
*
* @param text The text form of comment.
* @return The created comment.
*/
@Nullable
private XmlComment createXmlComment(@NotNull String text) {
// XmlElementFactory does not provide API for creating comment.
// So we create a tag wrapping the comment, and extract comment from the created tag.
XmlElementFactory xmlElementFactory = XmlElementFactory.getInstance(myProject);
XmlTag commentElement = xmlElementFactory.createTagFromText("<foo>" + text + "</foo>", XMLLanguage.INSTANCE);
return PsiTreeUtil.getChildOfType(commentElement, XmlComment.class);
}
/**
* 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;
}
/**
* Gets names of class members, given the type name.
*
* @param typeName The type presentable name.
* @return The members' names
*/
@NotNull
private List<String> getFieldNameByType(@NotNull String typeName) {
List<String> result = Lists.newArrayList();
PsiField[] psiFields = myActivity.getFields();
for (PsiField psiField : psiFields) {
if (psiField.getType().getPresentableText().equals(typeName)) {
result.add(psiField.getName());
}
}
return result;
}
@Nullable
private PsiField getFieldByName(@NotNull String name) {
PsiField[] psiFields = myActivity.getFields();
for (PsiField psiField : psiFields) {
if (psiField.getName().equals(name)) {
return psiField;
}
}
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;
}
/**
* Returns all the fields' and local variables' names which have already been used.
*/
@NotNull
private Set<String> getUsedVariableName() {
Set<String> usedNames = Sets.newHashSet();
// name of PsiFields in PsiClass
PsiField[] psiFields = myActivity.getFields();
for (PsiField psiField : psiFields) {
usedNames.add(psiField.getName());
}
// name of PsiLocalVariables in PsiMethods in PsiClass
PsiMethod[] psiMethods = myActivity.getMethods();
for (PsiMethod psiMethod : psiMethods) {
PsiCodeBlock methodBody = psiMethod.getBody();
if (methodBody != null) {
List<PsiStatement> psiStatements = StatementFilter.filterCodeBlock("", methodBody);
for (PsiStatement psiStatement : psiStatements) {
if (psiStatement instanceof PsiDeclarationStatement) {
PsiElement[] declaredElements = ((PsiDeclarationStatement)psiStatement).getDeclaredElements();
for (PsiElement declaredElement : declaredElements) {
usedNames.add(((PsiLocalVariable)declaredElement).getName());
}
}
}
}
}
return usedNames;
}
static class ApiCreatorException extends Exception {
public ApiCreatorException(String message) {
super(message);
}
}
}