/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.idea.gradle.project;
import com.android.SdkConstants;
import com.android.tools.idea.gradle.GradleSyncState;
import com.android.tools.idea.gradle.invoker.GradleInvoker;
import com.android.tools.idea.gradle.util.FilePaths;
import com.android.tools.idea.gradle.util.GradleUtil;
import com.android.tools.idea.gradle.util.LocalProperties;
import com.android.tools.idea.gradle.util.Projects;
import com.android.tools.idea.startup.AndroidStudioSpecificInitializer;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.intellij.openapi.application.Application;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.command.CommandProcessor;
import com.intellij.openapi.components.ServiceManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.externalSystem.model.DataNode;
import com.intellij.openapi.externalSystem.model.ExternalSystemDataKeys;
import com.intellij.openapi.externalSystem.model.ProjectKeys;
import com.intellij.openapi.externalSystem.model.ProjectSystemId;
import com.intellij.openapi.externalSystem.model.project.ModuleData;
import com.intellij.openapi.externalSystem.model.project.ProjectData;
import com.intellij.openapi.externalSystem.service.execution.ProgressExecutionMode;
import com.intellij.openapi.externalSystem.service.project.ExternalProjectRefreshCallback;
import com.intellij.openapi.externalSystem.service.project.manage.ProjectDataManager;
import com.intellij.openapi.externalSystem.util.DisposeAwareProjectChange;
import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil;
import com.intellij.openapi.externalSystem.util.ExternalSystemBundle;
import com.intellij.openapi.externalSystem.util.ExternalSystemUtil;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.module.ModuleManager;
import com.intellij.openapi.options.ConfigurationException;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.project.ProjectManager;
import com.intellij.openapi.roots.CompilerProjectExtension;
import com.intellij.openapi.roots.LanguageLevelProjectExtension;
import com.intellij.openapi.roots.ex.ProjectRootManagerEx;
import com.intellij.openapi.roots.impl.libraries.ProjectLibraryTable;
import com.intellij.openapi.roots.libraries.Library;
import com.intellij.openapi.roots.libraries.LibraryTable;
import com.intellij.openapi.startup.StartupManager;
import com.intellij.openapi.ui.Messages;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.util.io.FileUtilRt;
import com.intellij.openapi.vfs.VfsUtilCore;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.pom.java.LanguageLevel;
import com.intellij.ui.AppUIUtil;
import com.intellij.util.SystemProperties;
import com.intellij.util.ui.UIUtil;
import org.jetbrains.android.facet.AndroidFacet;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.plugins.gradle.settings.GradleProjectSettings;
import org.jetbrains.plugins.gradle.settings.GradleSettings;
import org.jetbrains.plugins.gradle.util.GradleConstants;
import java.io.File;
import java.io.IOException;
import java.util.Collection;
import static com.intellij.notification.NotificationType.ERROR;
import static com.intellij.openapi.externalSystem.service.execution.ProgressExecutionMode.MODAL_SYNC;
/**
* Imports an Android-Gradle project without showing the "Import Project" Wizard UI.
*/
public class GradleProjectImporter {
private static final Logger LOG = Logger.getInstance(GradleProjectImporter.class);
private static final ProjectSystemId SYSTEM_ID = GradleConstants.SYSTEM_ID;
private final ImporterDelegate myDelegate;
/**
* Flag used by unit tests to selectively disable code which requires an open project or UI updates; this is used
* by unit tests that do not run all of IntelliJ (e.g. do not extend the IdeaTestCase base)
*/
public static boolean ourSkipSetupFromTest;
@NotNull
public static GradleProjectImporter getInstance() {
return ServiceManager.getService(GradleProjectImporter.class);
}
public GradleProjectImporter() {
myDelegate = new ImporterDelegate();
}
@VisibleForTesting
GradleProjectImporter(ImporterDelegate delegate) {
myDelegate = delegate;
}
/**
* Imports the given Gradle project.
*
* @param selectedFile the selected build.gradle or the project's root directory.
*/
public void importProject(@NotNull VirtualFile selectedFile) {
VirtualFile projectDir = selectedFile.isDirectory() ? selectedFile : selectedFile.getParent();
File projectDirPath = new File(FileUtil.toSystemDependentName(projectDir.getPath()));
// Sync Android SDKs paths *before* importing project. Studio will freeze if the project has a local.properties file pointing to a SDK
// path that does not exist. The cause is that having 2 dialogs: one modal (the "Project Import" one) and another from
// Messages.showErrorDialog (indicating the Android SDK path does not exist) produce a deadlock.
try {
LocalProperties localProperties = new LocalProperties(projectDirPath);
if (AndroidStudioSpecificInitializer.isAndroidStudio()) {
SdkSync.syncIdeAndProjectAndroidHomes(localProperties);
}
}
catch (IOException e) {
LOG.info("Failed to sync SDKs", e);
Messages.showErrorDialog(e.getMessage(), "Project Import");
return;
}
// Set up Gradle settings. Otherwise we get an "already disposed project" error.
new GradleSettings(ProjectManager.getInstance().getDefaultProject());
createProjectFileForGradleProject(selectedFile, null);
}
/**
* Creates IntelliJ project file in the root of the project directory.
*
* @param selectedFile <code>build.gradle</code> in the module folder.
* @param parentProject existing parent project or <code>null</code> if a new one should be created.
*/
private void createProjectFileForGradleProject(@NotNull VirtualFile selectedFile, @Nullable Project parentProject) {
VirtualFile projectDir = selectedFile.isDirectory() ? selectedFile : selectedFile.getParent();
File projectDirPath = VfsUtilCore.virtualToIoFile(projectDir);
try {
importProject(projectDir.getName(), projectDirPath, true, new NewProjectImportGradleSyncListener() {
@Override
public void syncSucceeded(@NotNull Project project) {
activateProjectView(project);
}
}, parentProject, null);
}
catch (Exception e) {
if (ApplicationManager.getApplication().isUnitTestMode()) {
throw new RuntimeException(e);
}
Messages.showErrorDialog(e.getMessage(), "Project Import");
LOG.error(e);
}
}
/**
* Requests a project sync with Gradle. If the project import is successful,
* {@link com.android.tools.idea.gradle.util.ProjectBuilder#generateSourcesOnly()} will be invoked at the end.
*
* @param project the given project. This method does nothing if the project is not an Android-Gradle project.
* @param listener called after the project has been imported.
*/
public void requestProjectSync(@NotNull final Project project, @Nullable GradleSyncListener listener) {
requestProjectSync(project, true, listener);
}
/**
* Requests a project sync with Gradle.
*
* @param project the given project. This method does nothing if the project is not an Android-Gradle project.
* @param generateSourcesOnSuccess indicates whether the IDE should invoke Gradle to generate Java sources after a successful project
* import.
* @param listener called after the project has been imported.
*/
public void requestProjectSync(@NotNull final Project project,
final boolean generateSourcesOnSuccess,
@Nullable final GradleSyncListener listener) {
Runnable syncRequest = new Runnable() {
@Override
public void run() {
try {
doRequestSync(project, ProgressExecutionMode.IN_BACKGROUND_ASYNC, generateSourcesOnSuccess, listener);
}
catch (ConfigurationException e) {
Messages.showErrorDialog(project, e.getMessage(), e.getTitle());
}
}
};
AppUIUtil.invokeLaterIfProjectAlive(project, syncRequest);
}
public void syncProjectSynchronously(@NotNull final Project project,
final boolean generateSourcesOnSuccess,
@Nullable final GradleSyncListener listener) {
Runnable syncRequest = new Runnable() {
@Override
public void run() {
try {
doRequestSync(project, MODAL_SYNC, generateSourcesOnSuccess, listener);
}
catch (ConfigurationException e) {
Messages.showErrorDialog(project, e.getMessage(), e.getTitle());
}
}
};
UIUtil.invokeAndWaitIfNeeded(syncRequest);
}
private void doRequestSync(@NotNull final Project project,
@NotNull ProgressExecutionMode progressExecutionMode,
boolean generateSourcesOnSuccess,
@Nullable final GradleSyncListener listener) throws ConfigurationException {
if (Projects.isGradleProject(project) || hasTopLevelGradleBuildFile(project)) {
FileDocumentManager.getInstance().saveAllDocuments();
setUpGradleSettings(project);
resetProject(project);
doImport(project, false /* existing project */, progressExecutionMode, generateSourcesOnSuccess, listener);
}
else {
Runnable notificationTask = new Runnable() {
@Override
public void run() {
String msg = String.format("The project '%s' is not a Gradle-based project", project.getName());
AndroidGradleNotification.getInstance(project).showBalloon("Project Sync", msg, ERROR, new OpenMigrationToGradleUrlHyperlink());
if (listener != null) {
listener.syncFailed(project, msg);
}
}
};
Application application = ApplicationManager.getApplication();
if (application.isDispatchThread()) {
notificationTask.run();
}
else {
application.invokeLater(notificationTask);
}
}
}
private static boolean hasTopLevelGradleBuildFile(@NotNull Project project) {
VirtualFile baseDir = project.getBaseDir();
VirtualFile gradleBuildFile = baseDir.findChild(SdkConstants.FN_BUILD_GRADLE);
return gradleBuildFile != null && gradleBuildFile.exists() && !gradleBuildFile.isDirectory();
}
// See issue: https://code.google.com/p/android/issues/detail?id=64508
private static void resetProject(@NotNull final Project project) {
ExternalSystemApiUtil.executeProjectChangeAction(true, new DisposeAwareProjectChange(project) {
@Override
public void execute() {
LibraryTable libraryTable = ProjectLibraryTable.getInstance(project);
LibraryTable.ModifiableModel model = libraryTable.getModifiableModel();
try {
for (Library library : model.getLibraries()) {
model.removeLibrary(library);
}
}
finally {
model.commit();
}
// Remove all AndroidProjects from module. Otherwise, if re-import/sync fails, editors will not show the proper notification of
// the failure.
ModuleManager moduleManager = ModuleManager.getInstance(project);
for (Module module : moduleManager.getModules()) {
AndroidFacet facet = AndroidFacet.getInstance(module);
if (facet != null) {
facet.setIdeaAndroidProject(null);
}
}
}
});
}
/**
* Imports and opens an Android project that has been created with the "New Project" wizard. This method does not perform any project
* validation before importing the project (assuming that the wizard properly created the new project.)
*
* @param projectName name of the project.
* @param projectRootDir root directory of the project.
* @param listener called after the project has been imported.
* @param project the given project. This method does nothing if the project is not an Android-Gradle project.
* @param initialLanguageLevel when creating a new project, sets the language level to the given version early on (this is because you
* cannot set a language level later on in the process without telling the user that the language level
* has changed and to re-open the project)
* @throws IOException if any file I/O operation fails (e.g. creating the '.idea' directory.)
* @throws ConfigurationException if any required configuration option is missing (e.g. Gradle home directory path.)
*/
public void importNewlyCreatedProject(@NotNull String projectName,
@NotNull File projectRootDir,
@Nullable GradleSyncListener listener,
@Nullable Project project,
@Nullable LanguageLevel initialLanguageLevel) throws IOException, ConfigurationException {
doImport(projectName, projectRootDir, true, listener, project, initialLanguageLevel);
}
/**
* Imports and opens an Android project.
*
* @param projectName name of the project.
* @param projectRootDir root directory of the project.
* @param generateSourcesOnSuccess whether to generate sources after sync.
* @param listener called after the project has been imported.
* @param project the given project. This method does nothing if the project is not an Android-Gradle project.
* @param initialLanguageLevel when creating a new project, sets the language level to the given version early on (this is because you
* cannot set a language level later on in the process without telling the user that the language level
* has changed and to re-open the project)
* @throws IOException if any file I/O operation fails (e.g. creating the '.idea' directory.)
* @throws ConfigurationException if any required configuration option is missing (e.g. Gradle home directory path.)
*/
public void importProject(@NotNull String projectName,
@NotNull File projectRootDir,
boolean generateSourcesOnSuccess,
@Nullable GradleSyncListener listener,
@Nullable Project project,
@Nullable LanguageLevel initialLanguageLevel) throws IOException, ConfigurationException {
doImport(projectName, projectRootDir, generateSourcesOnSuccess, listener, project, initialLanguageLevel);
}
private void doImport(@NotNull String projectName,
@NotNull File projectRootDir,
boolean generateSourcesOnSuccess,
@Nullable GradleSyncListener listener,
@Nullable Project project,
@Nullable LanguageLevel initialLanguageLevel) throws IOException, ConfigurationException {
createTopLevelBuildFileIfNotExisting(projectRootDir);
createIdeaProjectDir(projectRootDir);
Project newProject = project == null ? createProject(projectName, projectRootDir.getPath()) : project;
setUpProject(newProject, initialLanguageLevel);
if (!ApplicationManager.getApplication().isUnitTestMode()) {
newProject.save();
}
doImport(newProject, true /* new project */, MODAL_SYNC /* synchronous import */, generateSourcesOnSuccess, listener);
}
private static void createTopLevelBuildFileIfNotExisting(@NotNull File projectRootDir) throws IOException {
File projectFile = GradleUtil.getGradleBuildFilePath(projectRootDir);
if (projectFile.isFile()) {
return;
}
FileUtilRt.createIfNotExists(projectFile);
String contents = "// Top-level build file where you can add configuration options common to all sub-projects/modules." +
SystemProperties.getLineSeparator();
FileUtil.writeToFile(projectFile, contents);
}
private static void createIdeaProjectDir(@NotNull File projectRootDir) throws IOException {
File ideaDir = new File(projectRootDir, Project.DIRECTORY_STORE_FOLDER);
if (ideaDir.isDirectory()) {
// "libraries" is hard-coded in com.intellij.openapi.roots.impl.libraries.ProjectLibraryTable
File librariesDir = new File(ideaDir, "libraries");
if (librariesDir.exists()) {
// remove contents of libraries. This is useful when importing existing projects that may have invalid library entries (e.g.
// created with Studio 0.4.3 or earlier.)
boolean librariesDirDeleted = FileUtil.delete(librariesDir);
if (!librariesDirDeleted) {
LOG.info(String.format("Failed to delete %1$s'", librariesDir.getPath()));
}
}
}
else {
FileUtil.ensureExists(ideaDir);
}
}
@NotNull
private static Project createProject(@NotNull String projectName, @NotNull String projectPath) throws ConfigurationException {
ProjectManager projectManager = ProjectManager.getInstance();
Project newProject = projectManager.createProject(projectName, projectPath);
if (newProject == null) {
throw new NullPointerException("Failed to create a new IDEA project");
}
return newProject;
}
private static void setUpProject(@NotNull final Project newProject, @Nullable final LanguageLevel initialLanguageLevel) {
CommandProcessor.getInstance().executeCommand(newProject, new Runnable() {
@Override
public void run() {
ApplicationManager.getApplication().runWriteAction(new Runnable() {
@Override
public void run() {
if (initialLanguageLevel != null) {
final LanguageLevelProjectExtension extension = LanguageLevelProjectExtension.getInstance(newProject);
if (extension != null) {
extension.setLanguageLevel(initialLanguageLevel);
}
}
// In practice, it really does not matter where the compiler output folder is. Gradle handles that. This is done just to please
// IDEA.
File compilerOutputDir = new File(newProject.getBasePath(), FileUtil.join(GradleUtil.BUILD_DIR_DEFAULT_NAME, "classes"));
String compilerOutputDirUrl = FilePaths.pathToIdeaUrl(compilerOutputDir);
CompilerProjectExtension compilerProjectExt = CompilerProjectExtension.getInstance(newProject);
assert compilerProjectExt != null;
compilerProjectExt.setCompilerOutputUrl(compilerOutputDirUrl);
setUpGradleSettings(newProject);
}
});
}
}, null, null);
}
private static void setUpGradleSettings(@NotNull Project project) {
GradleProjectSettings projectSettings = GradleUtil.getGradleProjectSettings(project);
if (projectSettings == null) {
projectSettings = new GradleProjectSettings();
}
projectSettings.setUseAutoImport(false);
setUpGradleProjectSettings(project, projectSettings);
GradleSettings gradleSettings = GradleSettings.getInstance(project);
gradleSettings.setLinkedProjectsSettings(ImmutableList.of(projectSettings));
}
private static void setUpGradleProjectSettings(@NotNull Project project, @NotNull GradleProjectSettings settings) {
settings.setExternalProjectPath(FileUtil.toCanonicalPath(project.getBasePath()));
}
private void doImport(@NotNull final Project project,
final boolean newProject,
@NotNull final ProgressExecutionMode progressExecutionMode,
boolean generateSourcesOnSuccess,
@Nullable final GradleSyncListener listener) throws ConfigurationException {
if (!PreSyncChecks.canSync(project)) {
// User should have already warned that something is not right and sync cannot continue.
GradleSyncState syncState = GradleSyncState.getInstance(project);
syncState.syncStarted(true);
NewProjectImportGradleSyncListener.createTopLevelProjectAndOpen(project);
syncState.syncFailed("Issues with settings.gradle file (e.g. empty file)");
return;
}
if (AndroidStudioSpecificInitializer.isAndroidStudio() && Projects.isDirectGradleInvocationEnabled(project)) {
// We cannot do the same when using JPS. We don't have access to the contents of the Message view used by JPS.
// For now, we can only improve the user experience in Android Studio.
GradleInvoker.getInstance(project).clearConsoleAndBuildMessages();
}
// Prevent IDEA from syncing with Gradle. We want to have full control of syncing.
project.putUserData(ExternalSystemDataKeys.NEWLY_IMPORTED_PROJECT, true);
project.putUserData(Projects.HAS_UNRESOLVED_DEPENDENCIES, false);
project.putUserData(Projects.HAS_WRONG_JDK, false);
final Application application = ApplicationManager.getApplication();
final boolean isTest = application.isUnitTestMode();
PostProjectSetupTasksExecutor.getInstance(project).setGenerateSourcesAfterSync(generateSourcesOnSuccess);
// We only update UI on sync when re-importing projects. By "updating UI" we mean updating the "Build Variants" tool window and editor
// notifications. It is not safe to do this for new projects because the new project has not been opened yet.
GradleSyncState.getInstance(project).syncStarted(!newProject);
myDelegate.importProject(project, new ExternalProjectRefreshCallback() {
@Override
public void onSuccess(@Nullable final DataNode<ProjectData> projectInfo) {
assert projectInfo != null;
Runnable runnable = new Runnable() {
@Override
public void run() {
populateProject(project, projectInfo);
if (!isTest || !ourSkipSetupFromTest) {
if (newProject) {
Projects.open(project);
}
if (!isTest) {
project.save();
}
}
if (newProject) {
// We need to do this because AndroidGradleProjectComponent#projectOpened is being called when the project is created, instead
// of when the project is opened. When 'projectOpened' is called, the project is not fully configured, and it does not look
// like it is Gradle-based, resulting in listeners (e.g. modules added events) not being registered. Here we force the
// listeners to be registered.
AndroidGradleProjectComponent projectComponent = ServiceManager.getService(project, AndroidGradleProjectComponent.class);
projectComponent.configureGradleProject(false);
}
if (listener != null) {
listener.syncSucceeded(project);
}
}
};
if (application.isUnitTestMode()) {
runnable.run();
}
else {
application.invokeLater(runnable);
}
}
@Override
public void onFailure(@NotNull final String errorMessage, @Nullable String errorDetails) {
if (errorDetails != null) {
LOG.warn(errorDetails);
}
String newMessage = ExternalSystemBundle.message("error.resolve.with.reason", errorMessage);
LOG.info(newMessage);
GradleSyncState.getInstance(project).syncFailed(newMessage);
if (listener != null) {
listener.syncFailed(project, newMessage);
}
}
}, progressExecutionMode);
}
private static void populateProject(@NotNull final Project newProject, @NotNull final DataNode<ProjectData> projectInfo) {
StartupManager.getInstance(newProject).runWhenProjectIsInitialized(new Runnable() {
@Override
public void run() {
ExternalSystemApiUtil.executeProjectChangeAction(new DisposeAwareProjectChange(newProject) {
@Override
public void execute() {
ProjectRootManagerEx.getInstanceEx(newProject).mergeRootsChangesDuring(new Runnable() {
@Override
public void run() {
ProjectDataManager dataManager = ServiceManager.getService(ProjectDataManager.class);
Collection<DataNode<ModuleData>> modules = ExternalSystemApiUtil.findAll(projectInfo, ProjectKeys.MODULE);
dataManager.importData(ProjectKeys.MODULE, modules, newProject, true /* synchronous */);
}
});
}
});
}
});
}
// Makes it possible to mock invocations to the Gradle Tooling API.
static class ImporterDelegate {
void importProject(@NotNull Project project,
@NotNull ExternalProjectRefreshCallback callback,
@NotNull final ProgressExecutionMode progressExecutionMode) throws ConfigurationException {
try {
String externalProjectPath = FileUtil.toCanonicalPath(project.getBasePath());
ExternalSystemUtil
.refreshProject(project, SYSTEM_ID, externalProjectPath, callback, false /* resolve dependencies */, progressExecutionMode, true /* always report import errors */);
}
catch (RuntimeException e) {
String externalSystemName = SYSTEM_ID.getReadableName();
throw new ConfigurationException(e.getMessage(), ExternalSystemBundle.message("error.cannot.parse.project", externalSystemName));
}
}
}
}