diff options
author | Yi Zhang <yizhan@google.com> | 2015-10-13 14:23:24 +0800 |
---|---|---|
committer | Yi Zhang <yizhan@google.com> | 2015-10-27 14:43:50 +0800 |
commit | 634e7e4b4946919c549783e6646b422890ec0dd7 (patch) | |
tree | 8f4ad730d8ff19e8f3a15e13a8eb930c894df3fc | |
parent | 88bcdbe3f21beea2e90b6dc19a3d220e38e7bd25 (diff) | |
download | appindexing-634e7e4b4946919c549783e6646b422890ec0dd7.tar.gz |
Add App Indexing API creation tool - a generator and an intention
action.
Change-Id: Id50bc3c95cae012b2a0b0e5936f2767689a0c80f
16 files changed, 1677 insertions, 0 deletions
diff --git a/google-appindexing.iml b/google-appindexing.iml index b67bd1a..6b396a9 100644 --- a/google-appindexing.iml +++ b/google-appindexing.iml @@ -37,5 +37,8 @@ <SOURCES /> </library> </orderEntry> + <orderEntry type="module" module-name="java-analysis-impl" /> + <orderEntry type="module" module-name="openapi" /> + <orderEntry type="module" module-name="java-impl" /> </component> </module>
\ No newline at end of file diff --git a/resources/intentionDescriptions/InsertApiCodeIntentionAction/after.gradle.template b/resources/intentionDescriptions/InsertApiCodeIntentionAction/after.gradle.template new file mode 100644 index 0000000..6b142ef --- /dev/null +++ b/resources/intentionDescriptions/InsertApiCodeIntentionAction/after.gradle.template @@ -0,0 +1,3 @@ +dependencies { + compile 'com.google.android.gms:play-services-appindexing:8.1.0' +} diff --git a/resources/intentionDescriptions/InsertApiCodeIntentionAction/after.java.template b/resources/intentionDescriptions/InsertApiCodeIntentionAction/after.java.template new file mode 100644 index 0000000..38dbb0a --- /dev/null +++ b/resources/intentionDescriptions/InsertApiCodeIntentionAction/after.java.template @@ -0,0 +1,66 @@ +package com.example.appindexing; + +import android.app.Activity; +import android.net.Uri; +import android.os.Bundle; + +import com.google.android.gms.appindexing.Action; +import com.google.android.gms.appindexing.AppIndex; +import com.google.android.gms.common.api.GoogleApiClient; + +public class MainActivity extends Activity { + /** + * ATTENTION: This was auto-generated to implement the App Indexing API. + * See https://g.co/AppIndexing/AndroidStudio for more information. + */ + private GoogleApiClient client; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // ATTENTION: This was auto-generated to implement the App Indexing API. + // See https://g.co/AppIndexing/AndroidStudio for more information. + client = new GoogleApiClient.Builder(this).addApi(AppIndex.API).build(); + } + + @Override + public void onStart() { + super.onStart(); + + // ATTENTION: This was auto-generated to implement the App Indexing API. + // See https://g.co/AppIndexing/AndroidStudio for more information. + client.connect(); + Action viewAction = Action.newAction( + Action.TYPE_VIEW, // TODO: choose an action type. + "Main Page", // TODO: Define a title for the content shown. + // TODO: If you have web page content that matches this app activity's content, + // make sure this auto-generated web page URL is correct. + // Otherwise, set the URL to null. + Uri.parse("http://www.example.com/main"), + // TODO: Make sure this auto-generated app deep link URI is correct. + Uri.parse("android-app://com.example.appindexing/http/www.example.com/main") + ); + AppIndex.AppIndexApi.start(client, viewAction); + } + + @Override + public void onStop() { + super.onStop(); + + // ATTENTION: This was auto-generated to implement the App Indexing API. + // See https://g.co/AppIndexing/AndroidStudio for more information. + Action viewAction = Action.newAction( + Action.TYPE_VIEW, // TODO: choose an action type. + "Main Page", // TODO: Define a title for the content shown. + // TODO: If you have web page content that matches this app activity's content, + // make sure this auto-generated web page URL is correct. + // Otherwise, set the URL to null. + Uri.parse("http://www.example.com/main"), + // TODO: Make sure this auto-generated app deep link URI is correct. + Uri.parse("android-app://com.example.appindexing/http/www.example.com/main") + ); + AppIndex.AppIndexApi.end(client, viewAction); + client.disconnect(); + } +} diff --git a/resources/intentionDescriptions/InsertApiCodeIntentionAction/after.xml.template b/resources/intentionDescriptions/InsertApiCodeIntentionAction/after.xml.template new file mode 100644 index 0000000..755a526 --- /dev/null +++ b/resources/intentionDescriptions/InsertApiCodeIntentionAction/after.xml.template @@ -0,0 +1,20 @@ +<manifest> + <application> + <activity android:name=".MainActivity" > + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.BROWSABLE" /> + <data + android:host="www.example.com" + android:pathPrefix="/main" + android:scheme="http" /> + </intent-filter> + </activity> + <!-- ATTENTION: This was auto-generated to add Google Play services to your project for + App Indexing. See https://g.co/AppIndexing/AndroidStudio for more information. --> + <meta-data + android:name="com.google.android.gms.version" + android:value="@integer/google_play_services_version" /> + </application> +</manifest> diff --git a/resources/intentionDescriptions/InsertApiCodeIntentionAction/before.gradle.template b/resources/intentionDescriptions/InsertApiCodeIntentionAction/before.gradle.template new file mode 100644 index 0000000..7d82dc7 --- /dev/null +++ b/resources/intentionDescriptions/InsertApiCodeIntentionAction/before.gradle.template @@ -0,0 +1,2 @@ +dependencies { +} diff --git a/resources/intentionDescriptions/InsertApiCodeIntentionAction/before.java.template b/resources/intentionDescriptions/InsertApiCodeIntentionAction/before.java.template new file mode 100644 index 0000000..ddc4ac7 --- /dev/null +++ b/resources/intentionDescriptions/InsertApiCodeIntentionAction/before.java.template @@ -0,0 +1,6 @@ +package com.example.appindexing; + +import android.app.Activity; + +public class MainActivity extends Activity { +} diff --git a/resources/intentionDescriptions/InsertApiCodeIntentionAction/before.xml.template b/resources/intentionDescriptions/InsertApiCodeIntentionAction/before.xml.template new file mode 100644 index 0000000..16cb528 --- /dev/null +++ b/resources/intentionDescriptions/InsertApiCodeIntentionAction/before.xml.template @@ -0,0 +1,15 @@ +<manifest> + <application> + <activity android:name=".MainActivity" > + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.BROWSABLE" /> + <data + android:host="www.example.com" + android:pathPrefix="/main" + android:scheme="http" /> + </intent-filter> + </activity> + </application> +</manifest> diff --git a/resources/intentionDescriptions/InsertApiCodeIntentionAction/description.html b/resources/intentionDescriptions/InsertApiCodeIntentionAction/description.html new file mode 100644 index 0000000..b6fa9f2 --- /dev/null +++ b/resources/intentionDescriptions/InsertApiCodeIntentionAction/description.html @@ -0,0 +1,22 @@ +<!-- + ~ 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. + --> +<html> +<body> +This intention creates App Indexing API code for publishing deep links. +<br /> +Please reference g.co/AppIndexing. +</body> +</html> diff --git a/src/META-INF/plugin.xml b/src/META-INF/plugin.xml index 4e3c378..c193878 100644 --- a/src/META-INF/plugin.xml +++ b/src/META-INF/plugin.xml @@ -15,6 +15,10 @@ <depends>com.google.gct.login</depends> <extensions defaultExtensionNs="com.intellij"> + <intentionAction> + <className>com.google.appindexing.actions.InsertApiCodeIntentionAction</className> + <category>Android</category> + </intentionAction> </extensions> <application-components> @@ -26,6 +30,10 @@ </project-components> <actions> + <action id="AppIndexingCodeGeneratorAction" class="com.google.appindexing.actions.GenerateApiCodeAction" + text="App Indexing API Code" description="Generate App Indexing API Code to Annotate this Activity for App Indexing"> + <add-to-group group-id="GenerateGroup" anchor="last"/> + </action> </actions> </idea-plugin> diff --git a/src/com/google/appindexing/actions/GenerateApiCodeAction.java b/src/com/google/appindexing/actions/GenerateApiCodeAction.java new file mode 100644 index 0000000..381f6c1 --- /dev/null +++ b/src/com/google/appindexing/actions/GenerateApiCodeAction.java @@ -0,0 +1,39 @@ +/* + * 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.actions; + +import com.google.appindexing.api.ApiCreator; + +import com.intellij.codeInsight.generation.actions.BaseGenerateAction; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.*; +import org.jetbrains.annotations.NotNull; + +/** + * Generates App Indexing API code. + */ +public class GenerateApiCodeAction extends BaseGenerateAction { + + GenerateApiCodeAction() { + super(new GenerateApiCodeHandler()); + } + + @Override + protected boolean isValidForFile(@NotNull Project project, @NotNull Editor editor, @NotNull PsiFile file) { + return super.isValidForFile(project, editor, file) && ApiCreator.eligibleForInsertingAppIndexingApiCode(editor, file); + } +} diff --git a/src/com/google/appindexing/actions/GenerateApiCodeHandler.java b/src/com/google/appindexing/actions/GenerateApiCodeHandler.java new file mode 100644 index 0000000..c697292 --- /dev/null +++ b/src/com/google/appindexing/actions/GenerateApiCodeHandler.java @@ -0,0 +1,40 @@ +/* + * 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.actions; + +import com.android.tools.idea.stats.UsageTracker; +import com.google.appindexing.api.ApiCreator; +import com.intellij.codeInsight.CodeInsightActionHandler; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; + +public class GenerateApiCodeHandler implements CodeInsightActionHandler { + + @Override + public void invoke(@NotNull final Project project, @NotNull Editor editor, @NotNull PsiFile file) { + UsageTracker.getInstance() + .trackEvent(UsageTracker.CATEGORY_APP_INDEXING, UsageTracker.ACTION_APP_INDEXING_API_CODE_CREATED, null, null); + ApiCreator creator = new ApiCreator(project, editor, file); + creator.insertAppIndexingApiCodeForActivity(); + } + + @Override + public boolean startInWriteAction() { + return true; + } +} diff --git a/src/com/google/appindexing/actions/InsertApiCodeIntentionAction.java b/src/com/google/appindexing/actions/InsertApiCodeIntentionAction.java new file mode 100644 index 0000000..43ec7e1 --- /dev/null +++ b/src/com/google/appindexing/actions/InsertApiCodeIntentionAction.java @@ -0,0 +1,60 @@ +/* + * 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.actions; + +import com.android.tools.idea.stats.UsageTracker; +import com.google.appindexing.api.ApiCreator; + +import com.intellij.codeInsight.intention.AbstractIntentionAction; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiFile; + +import org.jetbrains.annotations.NotNull; + +/** + * An intention action to insert App Indexing API code. + */ +public class InsertApiCodeIntentionAction extends AbstractIntentionAction { + + @Override + public String getText() { + return "Insert App Indexing API Code"; + } + + @Override + public boolean startInWriteAction() { + return true; + } + + @Override + public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) { + if (editor == null || file == null) { + return false; + } + return ApiCreator.eligibleForInsertingAppIndexingApiCode(editor, file); + } + + @Override + public void invoke(@NotNull final Project project, Editor editor, PsiFile file) { + if (editor != null && file != null) { + UsageTracker.getInstance() + .trackEvent(UsageTracker.CATEGORY_APP_INDEXING, UsageTracker.ACTION_APP_INDEXING_API_CODE_CREATED, null, null); + ApiCreator creator = new ApiCreator(project, editor, file); + creator.insertAppIndexingApiCodeForActivity(); + } + } +} diff --git a/src/com/google/appindexing/api/ApiCreator.java b/src/com/google/appindexing/api/ApiCreator.java new file mode 100644 index 0000000..ead753d --- /dev/null +++ b/src/com/google/appindexing/api/ApiCreator.java @@ -0,0 +1,1070 @@ +/* + * 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.annotations.VisibleForTesting; +import com.android.builder.model.AndroidArtifact; +import com.android.builder.model.AndroidLibrary; +import com.android.builder.model.MavenCoordinates; +import com.android.ide.common.repository.GradleCoordinate; +import com.android.tools.idea.gradle.AndroidGradleModel; +import com.android.tools.idea.gradle.dsl.dependencies.Dependencies; +import com.android.tools.idea.gradle.dsl.dependencies.ExternalDependencySpec; +import com.android.tools.idea.gradle.dsl.dependencies.external.ExternalDependency; +import com.android.tools.idea.gradle.dsl.model.GradleBuildModel; +import com.google.appindexing.util.DeepLinkUtils; +import com.google.appindexing.util.ManifestUtils; +import com.google.common.base.CharMatcher; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; + +import com.android.SdkConstants; +import com.android.tools.idea.gradle.project.GradleProjectImporter; +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.dependencies.CommonConfigurationNames.COMPILE; + +/** + * 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.1.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 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_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_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\n" + + " %4$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\n" + + " %4$s.AppIndexApi.end(%1$s, %2$s);\n" + + " %1$s.disconnect();\n" + + "}"; + + static private final String ACTION_FORMAT = "%6$s %1$s = %6$s.newAction(\n" + + " %6$s.TYPE_VIEW, // TODO: choose an action type.\n" + + " \"%2$s Page\", // TODO: Define a title for the content shown.\n" + + " // TODO: If you have web page content that matches this app activity's content,\n" + + " // make sure this auto-generated web page URL is correct.\n" + + " // Otherwise, set the URL to null.\n" + + " android.net.Uri.parse(\"http://%4$s\"), \n" + + " // TODO: Make sure this auto-generated app deep link URI is correct.\n" + + " android.net.Uri.parse(\"android-app://%3$s/%5$s/%4$s\")\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. -->"; + + 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); + } + + /** + * 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) { + GradleProjectImporter.getInstance().requestProjectSync(myProject, null); + } + } + } + + private void getGmsDependencyVersion() throws ApiCreatorException { + AndroidGradleModel model = AndroidGradleModel.get(myModule); + if (model == null) { + throw new ApiCreatorException("AndroidGradleModel not found."); + } + AndroidArtifact artifact = model.getMainArtifact(); + Collection<AndroidLibrary> libraries = artifact.getDependencies().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."); + } + Dependencies dependencies = buildModel.dependencies(); + String versionToUse = MINIMUM_VERSION; + if (myHighestGmsLibVersion != null && compareVersion(myHighestGmsLibVersion, MINIMUM_VERSION) > 0) { + versionToUse = myHighestGmsLibVersion; + } + + boolean gradleChange = false; + if (myAppIndexingLibVersion == null) { + ExternalDependencySpec newDependency = new ExternalDependencySpec(COMPILE_APPINDEXING, COMPILE_GMS_GROUP, versionToUse); + dependencies = buildModel.dependencies(); + dependencies.add(COMPILE, newDependency); + dependencies.applyChanges(); + gradleChange = true; + } + + if (myHighestGmsLibVersion != null && compareVersion(myHighestGmsLibVersion, versionToUse) < 0) { + ImmutableList<ExternalDependency> dependencyList = dependencies.external(); + for (ExternalDependency dependency : dependencyList) { + String group = dependency.group(); + if (group != null && group.equals(COMPILE_GMS_GROUP)) { + dependency.version(versionToUse); + dependency.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 actionName = getUnusedName("viewAction", VariableKind.LOCAL_VARIABLE); + String actionStatement = getActionStatement(deepLink, actionName); + addOrMergeOnStart(clientName, actionName, actionStatement); + addOrMergeOnStop(clientName, actionName, actionStatement); + + JavaCodeStyleManager.getInstance(myProject).shortenClassReferences(myActivity); + + unlockFromPsiOperation(myFile); + } + + /** + * 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) && + !ImportUtils.hasExactImportConflict(className.getValue(), (PsiJavaFile)myFile)) { + insertSingleImportIfNeeded(className.getValue()); + className.setValue(className.getKey()); + } + } + } + } + + /** + * 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 actionName The Action name. + * @param actionInitText The text of initializing statement for Action local variable. + */ + private void addOrMergeOnStart(@NotNull String clientName, @NotNull String actionName, @NotNull String actionInitText) { + if (myOnStart == null) { + String onStartMethod = String.format(ON_START_FORMAT, clientName, actionName, actionInitText, 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()) { + PsiElement actionStatement = myOnStart.add(myFactory.createStatementFromText(actionInitText, null)); + addCommentsBefore(COMMENT_IN_JAVA, myOnStart, actionStatement); + String startText = String.format(APP_INDEXING_START, clientName, actionName, myImportClasses.get(CLASS_APP_INDEX)); + myOnStart.add(myFactory.createStatementFromText(startText, null)); + } + } + } + + /** + * Generates app indexing api calls (GoogleApiClient's disconnect & end) in onStop method. + * + * @param clientName The GoogleApiClient name. + * @param actionName The Action name. + * @param actionInitText The initializing statement for Action local variable. + */ + private void addOrMergeOnStop(@NotNull String clientName, @NotNull String actionName, @NotNull String actionInitText) { + if (myOnStop == null) { + String onStopMethod = String.format(ON_STOP_FORMAT, clientName, actionName, actionInitText, 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, actionName, 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();" + myOnStop.addAfter(endStatement, superOnStopStatements.get(0)); + PsiStatement actionStatement = myFactory.createStatementFromText(actionInitText, null); + actionStatement = (PsiStatement)myOnStop.addAfter(actionStatement, superOnStopStatements.get(0)); + addCommentsBefore(COMMENT_IN_JAVA, myOnStop, actionStatement); + } + else { + // after "{" in the code block. + endStatement = (PsiStatement)myOnStop.addAfter(endStatement, myOnStop.getFirstBodyElement()); + PsiStatement actionStatement = myFactory.createStatementFromText(actionInitText, null); + actionStatement = (PsiStatement)myOnStop.addBefore(actionStatement, endStatement); + addCommentsBefore(COMMENT_IN_JAVA, myOnStop, actionStatement); + } + } + } + } + + /** + * 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; + } + + /** + * Generates Action initializing statement. + * + * @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 actionName The name of the Action local variable. + * @return The initializing statement. + */ + @NotNull + @VisibleForTesting + String getActionStatement(@Nullable String deepLink, @NotNull String actionName) { + String pageName = myActivity.getName(); + if (pageName.endsWith("Activity")) { + pageName = pageName.substring(0, pageName.length() - 8); + } + String packageName = ((PsiJavaFile)myFile).getPackageName(); + String scheme = (deepLink == null) ? "http" : deepLink.substring(0, deepLink.indexOf("://")); + String hostAndPath = getHostAndPathOfDeepLink(deepLink); + return String.format(ACTION_FORMAT, actionName, pageName, packageName, hostAndPath, scheme, myImportClasses.get(CLASS_ACTION)); + } + + /** + * Gets the non-scheme part of a deep link, including the host and the path / path prefix; + * + * @param deepLink The deep link string. + * @return The host and path of a deep link. + */ + @NotNull + private static String getHostAndPathOfDeepLink(@Nullable String deepLink) { + String hostAndPath = "host/path"; + if (deepLink != null) { + if (!deepLink.substring(deepLink.indexOf("://") + 3).isEmpty()) { + hostAndPath = deepLink.substring(deepLink.indexOf("://") + 3); + } + if (!hostAndPath.contains("/")) { + hostAndPath += "/path"; + } + } + return hostAndPath; + } + + /** + * 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. + */ + @Nullable + private 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. + */ + private 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); + } + } +} diff --git a/src/com/google/appindexing/api/StatementFilter.java b/src/com/google/appindexing/api/StatementFilter.java new file mode 100644 index 0000000..b809414 --- /dev/null +++ b/src/com/google/appindexing/api/StatementFilter.java @@ -0,0 +1,97 @@ +/* + * 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.google.common.base.CharMatcher; +import com.google.common.collect.Lists; + +import com.intellij.psi.JavaRecursiveElementVisitor; +import com.intellij.psi.PsiCodeBlock; +import com.intellij.psi.PsiDeclarationStatement; +import com.intellij.psi.PsiExpressionStatement; +import com.intellij.psi.PsiStatement; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * A filter for searching a part of code and getting statements which contains the filter string. + * <p/> + * Example: + * Given filter string "start", StatementFilter returns + * "AppIndex.AppIndexApi.start(mClient, viewAction);" for the code below. + * int a = 0; + * if (a == 0) { + * AppIndex.AppIndexApi.start(mClient, viewAction); + * } + */ +public final class StatementFilter extends JavaRecursiveElementVisitor { + + private String myFilterString; + + private List<PsiStatement> myStatements = Lists.newArrayList(); + + public StatementFilter(@NotNull String filterString) { + myFilterString = CharMatcher.WHITESPACE.removeFrom(filterString); + } + + @NotNull + public List<PsiStatement> getStatements() { + return myStatements; + } + + @Override + public void visitExpressionStatement(PsiExpressionStatement statement) { + if (CharMatcher.WHITESPACE.removeFrom(statement.getText()).contains(myFilterString)) { + myStatements.add(statement); + } + } + + @Override + public void visitDeclarationStatement(PsiDeclarationStatement statement) { + if (CharMatcher.WHITESPACE.removeFrom(statement.getText()).contains(myFilterString)) { + myStatements.add(statement); + } + } + + /** + * Filters statements in a code block with the filter string. + * + * @param codeBlock The code block to process. + * @return Statements that contain the string and pass the filter. + */ + @NotNull + public static List<PsiStatement> filterCodeBlock(@NotNull String filterString, @NotNull PsiCodeBlock codeBlock) { + StatementFilter filter = new StatementFilter(filterString); + codeBlock.accept(filter); + return filter.getStatements(); + } + + /** + * Filters statements in the list. + * + * @param statements The list of statements. + * @return Statements that contain the string and pass the filter. + */ + @NotNull + public static List<PsiStatement> filterStatements(@NotNull String filterString, @NotNull List<PsiStatement> statements) { + StatementFilter filter = new StatementFilter(filterString); + for (PsiStatement statement : statements) { + statement.accept(filter); + } + return filter.getStatements(); + } +} diff --git a/src/com/google/appindexing/util/DeepLinkUtils.java b/src/com/google/appindexing/util/DeepLinkUtils.java new file mode 100644 index 0000000..d2c8a7c --- /dev/null +++ b/src/com/google/appindexing/util/DeepLinkUtils.java @@ -0,0 +1,134 @@ +/* + * 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.util; + +import com.android.SdkConstants; +import com.google.common.collect.Lists; +import com.intellij.psi.xml.XmlTag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * Util functions for searching deep links. + * <p/> + * For more information, see + * http://developer.android.com/training/app-indexing/deep-linking.html + */ +public final class DeepLinkUtils { + private static final String TAG_ACTION = "action"; + private static final String TAG_CATEGORY = "category"; + + private static final String ACTION_VIEW = "android.intent.action.VIEW"; + private static final String CATEGORY_DEFAULT = "android.intent.category.DEFAULT"; + private static final String CATEGORY_BROWSABLE = "android.intent.category.BROWSABLE"; + + /** + * Returns all matching deep links from a root xml tag. + * + * @param root The root xml tag, usually is the root tag of AndroidManifest.xml + * @return All matching deep links + */ + @NotNull + public static List<String> getAllDeepLinks(@NotNull XmlTag root) { + List<XmlTag> intentFilters = ManifestUtils.searchXmlTagsByName(root, SdkConstants.TAG_INTENT_FILTER); + List<String> deepLinks = Lists.newArrayList(); + for (XmlTag intentFilter : intentFilters) { + String deepLink = getDeepLinkFromIntentFilter(intentFilter); + if (deepLink != null) { + deepLinks.add(deepLink); + } + } + return deepLinks; + } + + /** + * Returns the deep link within an intent filter xml tag. + */ + @Nullable + private static String getDeepLinkFromIntentFilter(@NotNull XmlTag intentFilter) { + // Check action. + List<XmlTag> actions = ManifestUtils.searchXmlTagsByName(intentFilter, TAG_ACTION); + boolean hasActionView = false; + for (XmlTag action : actions) { + String name = action.getAttributeValue(SdkConstants.ATTR_NAME, SdkConstants.NS_RESOURCES); + if (name != null && name.equals(ACTION_VIEW)) { + hasActionView = true; + break; + } + } + if (!hasActionView) { + return null; + } + + // Check category + List<XmlTag> categories = ManifestUtils.searchXmlTagsByName(intentFilter, TAG_CATEGORY); + boolean hasDefaultCategory = false; + boolean hasBrowsableCategory = false; + for (XmlTag category : categories) { + String name = category.getAttributeValue(SdkConstants.ATTR_NAME, SdkConstants.NS_RESOURCES); + if (name != null && name.equals(CATEGORY_DEFAULT)) { + hasDefaultCategory = true; + } + else if (name != null && name.equals(CATEGORY_BROWSABLE)) { + hasBrowsableCategory = true; + } + } + if (!hasDefaultCategory || !hasBrowsableCategory) { + return null; + } + + // Parse deep link + List<XmlTag> datas = ManifestUtils.searchXmlTagsByName(intentFilter, SdkConstants.TAG_DATA); + String scheme = null, host = null, pathPrefix = null, path = null; + for (XmlTag data : datas) { + if (scheme == null) { + scheme = data.getAttributeValue(SdkConstants.ATTR_SCHEME, SdkConstants.NS_RESOURCES); + } + if (host == null) { + host = data.getAttributeValue(SdkConstants.ATTR_HOST, SdkConstants.NS_RESOURCES); + } + if (pathPrefix == null) { + pathPrefix = data.getAttributeValue(SdkConstants.ATTR_PATH_PREFIX, SdkConstants.NS_RESOURCES); + } + if (path == null) { + path = data.getAttributeValue(SdkConstants.ATTR_PATH, SdkConstants.NS_RESOURCES); + } + } + + // Build a deep link, which should look something like + // "http://", "http://host", "http://host/path-prefix" or "http://host/path.html". + // Incomplete deep links are OK since we only want to generate what we can, + // and AppIndexingApiDetector will issue errors for users to fix bad deep links. + if (scheme != null) { + StringBuilder buf = new StringBuilder(scheme); + buf.append("://"); + if (host != null) { + buf.append(host); + if (path != null && path.startsWith("/")) { + buf.append(path); + } + else if (pathPrefix != null && pathPrefix.startsWith("/")) { + buf.append(pathPrefix); + } + } + return buf.toString(); + } + return null; + } +} + diff --git a/src/com/google/appindexing/util/ManifestUtils.java b/src/com/google/appindexing/util/ManifestUtils.java new file mode 100644 index 0000000..a373df7 --- /dev/null +++ b/src/com/google/appindexing/util/ManifestUtils.java @@ -0,0 +1,92 @@ +/* + * 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.util; + +import com.google.common.collect.Lists; + +import com.intellij.openapi.module.Module; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import com.intellij.psi.XmlRecursiveElementVisitor; +import com.intellij.psi.xml.XmlTag; +import com.intellij.psi.xml.XmlFile; + +import org.jetbrains.android.facet.AndroidFacet; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.util.List; + +/** + * Util functions for getting AndroidManifest.xml file or tags within the file. + */ +public final class ManifestUtils { + /** + * Returns the file of AndroidManifest.xml. + */ + @Nullable + public static VirtualFile getAndroidManiFest(@NotNull Module module) { + AndroidFacet facet = AndroidFacet.getInstance(module); + if (facet != null) { + File file = facet.getMainSourceProvider().getManifestFile(); + if (file != null) { + return LocalFileSystem.getInstance().findFileByIoFile(file); + } + } + return null; + } + + /** + * Returns the file of AndroidManifest.xml with type of XmlFile. + */ + @Nullable + public static XmlFile getAndroidManifestPsi(@NotNull Module module) { + VirtualFile manifest = getAndroidManiFest(module); + if (manifest != null) { + PsiFile psiFile = PsiManager.getInstance(module.getProject()).findFile(manifest); + if (psiFile instanceof XmlFile) { + return (XmlFile)psiFile; + } + } + return null; + } + + /** + * Searches xml tags with a specific tag name within a root tag. + * + * @param root The root tag to search in. + * @param tagName The tag name. + * @return All the xml tags with the name. + */ + @NotNull + public static List<XmlTag> searchXmlTagsByName(@NotNull XmlTag root, @NotNull final String tagName) { + final List<XmlTag> tags = Lists.newArrayList(); + root.accept(new XmlRecursiveElementVisitor() { + @Override + public void visitXmlTag(XmlTag tag) { + super.visitXmlTag(tag); + if (tag.getName().equalsIgnoreCase(tagName)) { + tags.add(tag); + } + } + }); + return tags; + } +} + |