/*
* Copyright (C) 2014 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.annotations.VisibleForTesting;
import com.android.builder.model.AndroidProject;
import com.android.sdklib.AndroidVersion;
import com.android.sdklib.IAndroidTarget;
import com.android.tools.idea.gradle.GradleSyncState;
import com.android.tools.idea.gradle.IdeaAndroidProject;
import com.android.tools.idea.gradle.IdeaGradleProject;
import com.android.tools.idea.gradle.JavaModel;
import com.android.tools.idea.gradle.facet.AndroidGradleFacet;
import com.android.tools.idea.gradle.facet.JavaGradleFacet;
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.sdk.DefaultSdks;
import com.android.tools.idea.startup.AndroidStudioSpecificInitializer;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.hash.Hashing;
import com.google.common.io.Closeables;
import com.google.common.io.Files;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.module.ModuleManager;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.projectRoots.Sdk;
import com.intellij.openapi.projectRoots.SdkAdditionalData;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.vfs.VfsUtil;
import com.intellij.openapi.vfs.VfsUtilCore;
import com.intellij.openapi.vfs.VirtualFile;
import org.jetbrains.android.facet.AndroidFacet;
import org.jetbrains.android.sdk.AndroidPlatform;
import org.jetbrains.android.sdk.AndroidSdkAdditionalData;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.lang.reflect.*;
import java.util.*;
import static com.android.SdkConstants.FN_FRAMEWORK_LIBRARY;
import static com.intellij.openapi.roots.OrderRootType.CLASSES;
/**
* The Project data that needs to be persisted for it to be possible to reload the Project without the need of calling Gradle to
* regenerate this objects.
*/
public class AndroidGradleProjectData implements Serializable {
@NotNull @NonNls private static final String STATE_FILE_NAME = "model_data.bin";
private static final boolean ENABLED = !Boolean.getBoolean("studio.disable.synccache");
@SuppressWarnings("unchecked") private static final Set<Class<?>> SUPPORTED_TYPES =
ImmutableSet.of(File.class, Boolean.class, String.class, Integer.class, Collection.class, Map.class, Set.class);
private static final Logger LOG = Logger.getInstance(AndroidGradleProjectData.class);
/**
* A map from module name to its data
*/
private Map<String, ModuleData> myData = Maps.newHashMap();
/**
* A set of files and their MD5 that this data depends on.
*/
private Map<String, byte[]> myFileChecksums = Maps.newHashMap();
/**
* The model version
*/
private String myGradlePluginVersion = SdkConstants.GRADLE_PLUGIN_LATEST_VERSION;
/**
* The last time a sync was done.
*/
private long myLastGradleSyncTimestamp = -1L;
private AndroidGradleProjectData() {
}
public static void removeFrom(@NotNull Project project) {
if (!ENABLED) {
return;
}
try {
File stateFile = getProjectStateFile(project);
if (stateFile.isFile()) {
FileUtil.delete(stateFile);
}
}
catch (IOException e) {
LOG.warn(String.format("Failed to remove state for project %1$s'", project.getName()));
}
}
/**
* Persists the gradle model of this project to disk.
*
* @param project the project to get the data from.
*/
public static void save(@NotNull Project project) {
if (!ENABLED) {
return;
}
try {
AndroidGradleProjectData data = createFrom(project);
if (data != null) {
File file = getProjectStateFile(project);
FileUtil.ensureExists(file.getParentFile());
data.saveTo(file);
}
}
catch (IOException e) {
LOG.info(String.format("Error while saving persistent state from project '%1$s'", project.getName()), e);
}
}
@Nullable
@VisibleForTesting
static AndroidGradleProjectData createFrom(@NotNull Project project) throws IOException {
AndroidGradleProjectData data = new AndroidGradleProjectData();
File rootDirPath = new File(project.getBasePath());
Module[] modules = ModuleManager.getInstance(project).getModules();
for (Module module : modules) {
ModuleData moduleData = new ModuleData();
moduleData.myName = module.getName();
AndroidFacet androidFacet = AndroidFacet.getInstance(module);
if (androidFacet != null) {
IdeaAndroidProject ideaAndroidProject = androidFacet.getIdeaAndroidProject();
if (ideaAndroidProject != null) {
moduleData.myAndroidProject = reproxy(AndroidProject.class, ideaAndroidProject.getDelegate());
moduleData.mySelectedVariant = ideaAndroidProject.getSelectedVariant().getName();
}
else {
LOG.warn(String.format("Trying to create project data from a not initialized project '%1$s'. Abort.", project.getName()));
return null;
}
}
AndroidGradleFacet gradleFacet = AndroidGradleFacet.getInstance(module);
if (gradleFacet != null) {
IdeaGradleProject ideaGradleProject = gradleFacet.getGradleProject();
if (ideaGradleProject != null) {
data.addFileDependency(rootDirPath, ideaGradleProject.getBuildFile());
moduleData.myIdeaGradleProject = ideaGradleProject;
}
else {
LOG.warn(String.format("Trying to create project data from a not initialized project '%1$s'. Abort.", project.getName()));
return null;
}
}
JavaGradleFacet javaFacet = JavaGradleFacet.getInstance(module);
if (javaFacet != null) {
moduleData.myJavaModel = javaFacet.getJavaModel();
}
if (Projects.isGradleProjectModule(module)) {
data.addFileDependency(rootDirPath, GradleUtil.getGradleBuildFile(module));
data.addFileDependency(rootDirPath, GradleUtil.getGradleSettingsFile(rootDirPath));
data.addFileDependency(rootDirPath, new File(rootDirPath, SdkConstants.FN_GRADLE_PROPERTIES));
data.addFileDependency(rootDirPath, new File(rootDirPath, SdkConstants.FN_LOCAL_PROPERTIES));
data.addFileDependency(rootDirPath, getGradleUserSettingsFile());
}
data.myData.put(moduleData.myName, moduleData);
}
GradleSyncState syncState = GradleSyncState.getInstance(project);
data.myLastGradleSyncTimestamp = syncState.getLastGradleSyncTimestamp();
return data;
}
@Nullable
public static File getGradleUserSettingsFile() {
String homePath = System.getProperty("user.home");
if (homePath == null) {
return null;
}
return new File(homePath, FileUtil.join(SdkConstants.DOT_GRADLE, SdkConstants.FN_GRADLE_PROPERTIES));
}
@NotNull
private static byte[] createChecksum(@NotNull File file) throws IOException {
// For files tracked by the IDE we get the content from the virtual files, otherwise we revert to io.
VirtualFile vf = VfsUtil.findFileByIoFile(file, true);
byte[] data = new byte[] {};
if (vf != null) {
vf.refresh(false, false);
if (vf.exists()) {
data = vf.contentsToByteArray();
}
} else if (file.exists()) {
data = Files.toByteArray(file);
}
return Hashing.md5().hashBytes(data).asBytes();
}
/**
* Loads the gradle model persisted on disk for the given project.
*
* @param project the project for which to load the data.
* @return whether the load was successful.
*/
public static boolean loadFromDisk(@NotNull final Project project) {
if (!ENABLED || needsAndroidSdkSync(project)) {
return false;
}
try {
return doLoadFromDisk(project);
}
catch (IOException e) {
LOG.info(String.format("Error accessing state cache for project '%1$s', sync will be needed.", project.getName()));
}
catch (ClassNotFoundException e) {
LOG.info(String.format("Cannot recover state cache for project '%1$s', sync will be needed.", project.getName()));
}
return false;
}
private static boolean needsAndroidSdkSync(@NotNull Project project) {
if (AndroidStudioSpecificInitializer.isAndroidStudio()) {
final File ideSdkPath = DefaultSdks.getDefaultAndroidHome();
if (ideSdkPath != null) {
if (needsLPreviewPlatformReset()) {
// reset the Android SDK home to force recreation of IDEA SDKs.
ApplicationManager.getApplication().runWriteAction(new Runnable() {
@Override
public void run() {
DefaultSdks.setDefaultAndroidHome(ideSdkPath, DefaultSdks.getDefaultJdk());
}
});
return true;
}
try {
LocalProperties localProperties = new LocalProperties(project);
File projectSdkPath = localProperties.getAndroidSdkPath();
return projectSdkPath == null || !FileUtil.filesEqual(ideSdkPath, projectSdkPath);
}
catch (IOException ignored) {
}
}
return true;
}
return false;
}
private static boolean needsLPreviewPlatformReset() {
// Repair SDK for 'android-L'. See: https://code.google.com/p/android/issues/detail?id=72589
// TODO: remove this at some point (it's only there to upgrade user settings for people who used 0.8.0 and 0.8.1 with 20 and 21
// installed simultaneously)
for (Sdk sdk : DefaultSdks.getEligibleAndroidSdks()) {
SdkAdditionalData additionalData = sdk.getSdkAdditionalData();
if (additionalData instanceof AndroidSdkAdditionalData) {
AndroidPlatform androidPlatform = ((AndroidSdkAdditionalData)additionalData).getAndroidPlatform();
if (androidPlatform != null) {
IAndroidTarget target = androidPlatform.getTarget();
AndroidVersion version = target.getVersion();
if ("L".equals(version.getApiString()) && version.getApiLevel() == 20 && version.isPreview()) {
// This is "android-L"
String androidJarPath = target.getPath(IAndroidTarget.ANDROID_JAR);
File expectedPath = new File(androidJarPath);
VirtualFile[] libraryFiles = sdk.getRootProvider().getFiles(CLASSES);
for (VirtualFile libraryFile : libraryFiles) {
// Match the expected path of android.jar vs. the actual path. The expected path is the one coming from SDK Manager, while
// the actual path is the one in the IDEA SDK.
if (FN_FRAMEWORK_LIBRARY.equals(libraryFile.getName())) {
File actualPath = VfsUtilCore.virtualToIoFile(libraryFile);
return !FileUtil.filesEqual(expectedPath, actualPath);
}
}
// android.jar was never found.
return true;
}
}
}
}
return false;
}
private static boolean doLoadFromDisk(@NotNull Project project) throws IOException, ClassNotFoundException {
FileInputStream fin = null;
try {
File rootDirPath = new File(FileUtil.toSystemDependentName(project.getBasePath()));
File dataFile = getProjectStateFile(project);
if (!dataFile.exists()) {
return false;
}
fin = new FileInputStream(dataFile);
ObjectInputStream ois = new ObjectInputStream(fin);
try {
AndroidGradleProjectData data = (AndroidGradleProjectData)ois.readObject();
if (data.validate(rootDirPath)) {
if (data.applyTo(project)) {
PostProjectSetupTasksExecutor.getInstance(project).onProjectRestoreFromDisk();
return true;
}
}
}
finally {
Closeables.close(ois, false);
}
}
finally {
Closeables.close(fin, false);
}
return false;
}
@NotNull
private static File getProjectStateFile(@NotNull Project project) throws IOException {
Module projectModule = Projects.findGradleProjectModule(project);
if (projectModule != null) {
File buildFolderPath = Projects.getBuildFolderPath(projectModule);
if (buildFolderPath != null) {
return new File(buildFolderPath, FileUtil.join(AndroidProject.FD_INTERMEDIATES, STATE_FILE_NAME));
}
}
// TODO: Once we upgrade to Gradle 2.0, we can get the build directory from there. For now assume "build".
return new File(VfsUtilCore.virtualToIoFile(project.getBaseDir()),
FileUtil.join(GradleUtil.BUILD_DIR_DEFAULT_NAME, AndroidProject.FD_INTERMEDIATES, STATE_FILE_NAME));
}
/**
* Regenerate proxy objects with a serializable version of a proxy.
* This method is intended to be run on objects that are a bag of properties, particularly custom Gradle model objects.
* Here we assume that the given object can be represented as a map of method name to return value. The original object
* is regenerated using this assumption which gives a serializable/deserializable object.
*
* If a method throws an exception it is assumed that it is the intended behaviour and the same exception will be thrown in the
* reproxied counterpart. This is useful for Gradle model methods that are not present in the actual model object being used.
*
* @param object the object to 'reproxy'.
* @param type the runtime type of the object. This is the expected type of object, and must be a superclass or equals to T.
* @param <T> the type of the object.
* @return the reproxied object.
*/
@SuppressWarnings("unchecked")
@Nullable
@VisibleForTesting
static <T> T reproxy(Type type, T object) {
if (object == null) {
return null;
}
if (object instanceof InvocationErrorValue) {
return object;
}
if (type instanceof ParameterizedType) {
ParameterizedType genericType = (ParameterizedType)type;
if (genericType.getRawType() instanceof Class) {
Class<?> genericClass = (Class<?>)genericType.getRawType();
if (Collection.class.isAssignableFrom(genericClass)) {
Collection<Object> collection = (Collection<Object>)object;
Collection<Object> newCollection;
if (genericClass.isAssignableFrom(ArrayList.class)) {
newCollection = Lists.newArrayListWithCapacity(collection.size());
}
else if (genericClass.isAssignableFrom(Set.class)) {
newCollection = Sets.newLinkedHashSet();
}
else {
throw new IllegalStateException("Unsupported collection type: " + genericClass.getCanonicalName());
}
Type argument = genericType.getActualTypeArguments()[0];
for (Object item : collection) {
newCollection.add(reproxy(argument, item));
}
return (T)newCollection;
}
else if (Map.class.isAssignableFrom(genericClass)) {
Map<Object, Object> map = (Map<Object, Object>)object;
Map<Object, Object> newMap = Maps.newLinkedHashMap();
Type keyType = genericType.getActualTypeArguments()[0];
Type valueType = genericType.getActualTypeArguments()[1];
for (Map.Entry entry : map.entrySet()) {
newMap.put(reproxy(keyType, entry.getKey()), reproxy(valueType, entry.getValue()));
}
return (T)newMap;
}
else {
throw new IllegalStateException("Unsupported generic type: " + genericClass.getCanonicalName());
}
}
else {
throw new IllegalStateException("Unsupported raw type.");
}
}
// Only modify proxy objects...
if (!Proxy.isProxyClass(object.getClass())) {
return object;
}
// ...that are not our own proxy.
if (Proxy.getInvocationHandler(object) instanceof WrapperInvocationHandler) {
return object;
}
Class<?>[] interfaces = object.getClass().getInterfaces();
if (interfaces.length != 1) {
throw new IllegalStateException("Cannot 'reproxy' a class with multiple interfaces");
}
Class<?> clazz = interfaces[0];
final Map<String, Object> values = Maps.newHashMap();
for (Method m : clazz.getMethods()) {
try {
if (Modifier.isPublic(m.getModifiers())) {
Object value;
try {
value = m.invoke(object);
} catch (InvocationTargetException e) {
value = new InvocationErrorValue(e.getCause());
}
values.put(m.toGenericString(), reproxy(m.getGenericReturnType(), value));
}
}
catch (IllegalAccessException e) {
throw new IllegalStateException("A non public method shouldn't have been called.", e);
}
}
return (T)Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{clazz}, new WrapperInvocationHandler(values));
}
@VisibleForTesting
static boolean isSupported(@NotNull Class<?> clazz) {
return clazz.isPrimitive() || SUPPORTED_TYPES.contains(clazz);
}
/**
* Adds a dependency to the content of the given virtual file. @see addFileDependency(File, File)
*/
private void addFileDependency(File rootDirPath, @Nullable VirtualFile vf) throws IOException {
addFileDependency(rootDirPath, vf != null ? VfsUtilCore.virtualToIoFile(vf) : null);
}
/**
* Adds a dependency to the content of the given file.
* <p/>
* This method saves a checksum of the content of the given file along with its location. If this file's content is later found
* to have changed, the persisted data will be considered invalid.
*
* @param rootDirPath the root directory.
* @param file the file to add the dependency for.
* @throws IOException if there is a problem accessing the given file.
*/
private void addFileDependency(File rootDirPath, @Nullable File file) throws IOException {
if (file == null) {
return;
}
String key;
if (FileUtil.isAncestor(rootDirPath, file, true)) {
key = FileUtil.getRelativePath(rootDirPath, file);
}
else {
key = file.getAbsolutePath();
}
myFileChecksums.put(key, createChecksum(file));
}
/**
* Validates that the received data can be applied to the project at rootDir.
* <p/>
* This validates that all the files this model depends on, still have the same content checksum and that the gradle model version
* is still the same.
*
* @param rootDir the root directory where to find the files.
* @return whether the data is still valid.
* @throws IOException if there is a problem accessing these files.
*/
private boolean validate(@NotNull File rootDir) throws IOException {
if (!myGradlePluginVersion.equals(SdkConstants.GRADLE_PLUGIN_LATEST_VERSION)) {
return false;
}
for (Map.Entry<String, byte[]> entry : myFileChecksums.entrySet()) {
File file = new File(entry.getKey());
if (!file.isAbsolute()) {
file = new File(rootDir, file.getPath());
}
if (!Arrays.equals(entry.getValue(), createChecksum(file))) {
return false;
}
}
return true;
}
/**
* Applies this data to the given project.
*
* @param project the project to apply the data to.
*/
@VisibleForTesting
public boolean applyTo(Project project) {
final Module[] modules = ModuleManager.getInstance(project).getModules();
for (Module module : modules) {
ModuleData data = myData.get(module.getName());
// If no data is found, the cache doesn't match the project structure and we should resync.
if (data == null) {
return false;
}
AndroidFacet androidFacet = AndroidFacet.getInstance(module);
String moduleFilePath = module.getModuleFilePath(); // System dependent absolute path.
if (androidFacet != null) {
if (data.myAndroidProject != null) {
File moduleFile = new File(moduleFilePath);
assert moduleFile.getParent() != null : moduleFile.getPath();
File moduleRootDirPath = moduleFile.getParentFile();
IdeaAndroidProject ideaAndroidProject =
new IdeaAndroidProject(module.getName(), moduleRootDirPath, data.myAndroidProject, data.mySelectedVariant);
androidFacet.setIdeaAndroidProject(ideaAndroidProject);
}
else {
return false;
}
}
AndroidGradleFacet gradleFacet = AndroidGradleFacet.getInstance(module);
if (gradleFacet != null) {
gradleFacet.setGradleProject(data.myIdeaGradleProject);
}
JavaGradleFacet javaFacet = JavaGradleFacet.getInstance(module);
if (javaFacet != null && data.myJavaModel != null) {
javaFacet.setJavaModel(data.myJavaModel);
}
}
GradleSyncState.getInstance(project).syncSkipped(myLastGradleSyncTimestamp);
return true;
}
/**
* Saves the data on the given project location.
*
* @param file the file where to save this data.
*/
private void saveTo(File file) throws IOException {
FileOutputStream fos = null;
try {
fos = new FileOutputStream(file);
ObjectOutputStream oos = new ObjectOutputStream(fos);
try {
oos.writeObject(this);
}
finally {
Closeables.close(oos, false);
}
}
finally {
Closeables.close(fos, false);
}
}
@VisibleForTesting
Map<String, ModuleData> getModuleData() {
return myData;
}
@VisibleForTesting
Map<String, byte[]> getFileChecksums() {
return myFileChecksums;
}
/**
* The persistent data to store per project Module.
*/
static class ModuleData implements Serializable {
public String myName;
public IdeaGradleProject myIdeaGradleProject;
public AndroidProject myAndroidProject;
public String mySelectedVariant;
public JavaModel myJavaModel;
}
static class WrapperInvocationHandler implements InvocationHandler, Serializable {
private static final Method TO_STRING = getObjectMethod("toString");
private static final Method HASHCODE = getObjectMethod("hashCode");
private static final Method EQUALS = getObjectMethod("equals", Object.class);
private final Map<String, Object> values;
WrapperInvocationHandler(@NotNull Map<String, Object> values) {
this.values = values;
}
@NotNull
private static Method getObjectMethod(@NotNull String name, @NotNull Class<?>... types) {
try {
return Object.class.getMethod(name, types);
}
catch (NoSuchMethodException e) {
throw new IllegalStateException("Method should exist in Object", e);
}
}
@Override
public Object invoke(Object o, Method method, Object[] objects) throws Throwable {
if (method.equals(TO_STRING)) {
return method.invoke(this, objects);
}
else if (method.equals(HASHCODE)) {
return method.invoke(this, objects);
}
else if (method.equals(EQUALS)) {
return proxyEquals(objects[0]);
}
else {
String key = method.toGenericString();
if (!values.containsKey(key)) {
LOG.warn("Invoking a non-existent reproxy method: " + key);
}
Object value = values.get(key);
if (value instanceof InvocationErrorValue) {
throw ((InvocationErrorValue)value).exception;
}
return value;
}
}
private boolean proxyEquals(Object other) {
return other != null && Proxy.isProxyClass(other.getClass()) && Proxy.getInvocationHandler(other).equals(this);
}
}
private static class InvocationErrorValue implements Serializable {
public Throwable exception;
private InvocationErrorValue(Throwable exception) {
this.exception = exception;
}
}
}