| /* |
| * 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.structure.gradle; |
| |
| import com.android.SdkConstants; |
| import com.android.builder.model.ApiVersion; |
| import com.android.ide.common.repository.GradleCoordinate; |
| import com.android.sdklib.AndroidVersion; |
| import com.android.tools.idea.gradle.IdeaAndroidProject; |
| import com.android.tools.idea.templates.RepositoryUrlManager; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableMap; |
| import com.google.common.collect.Lists; |
| import com.google.common.collect.Sets; |
| import com.intellij.icons.AllIcons; |
| import com.intellij.openapi.diagnostic.Logger; |
| import com.intellij.openapi.module.Module; |
| import com.intellij.openapi.project.Project; |
| import com.intellij.openapi.ui.DialogWrapper; |
| import com.intellij.openapi.ui.TextFieldWithBrowseButton; |
| import com.intellij.openapi.ui.ValidationInfo; |
| import com.intellij.openapi.util.Disposer; |
| import com.intellij.openapi.util.text.StringUtil; |
| import com.intellij.ui.CollectionComboBoxModel; |
| import com.intellij.ui.components.JBList; |
| import com.intellij.util.io.HttpRequests; |
| import com.intellij.util.ui.AsyncProcessIcon; |
| import org.jdom.Element; |
| import org.jdom.JDOMException; |
| import org.jdom.input.SAXBuilder; |
| import org.jdom.xpath.XPath; |
| import org.jetbrains.android.facet.AndroidFacet; |
| import org.jetbrains.annotations.NotNull; |
| import org.jetbrains.annotations.Nullable; |
| import org.jetbrains.idea.maven.model.MavenArtifactInfo; |
| import org.jetbrains.idea.maven.utils.MavenLog; |
| |
| import javax.swing.*; |
| import javax.swing.event.ListSelectionEvent; |
| import javax.swing.event.ListSelectionListener; |
| import java.awt.event.ActionEvent; |
| import java.awt.event.ActionListener; |
| import java.awt.event.MouseAdapter; |
| import java.awt.event.MouseEvent; |
| import java.io.IOException; |
| import java.util.*; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.Executors; |
| |
| import static com.android.tools.idea.templates.RepositoryUrlManager.REVISION_ANY; |
| |
| public class MavenDependencyLookupDialog extends DialogWrapper { |
| private static final String AAR_PACKAGING = "@" + SdkConstants.EXT_AAR; |
| private static final String JAR_PACKAGING = "@" + SdkConstants.EXT_JAR; |
| private static final int RESULT_LIMIT = 50; |
| private static final String MAVEN_CENTRAL_SEARCH_URL = "https://search.maven.org/solrsearch/select?rows=%d&wt=xml&q=\"%s\""; |
| private static final Logger LOG = Logger.getInstance(MavenDependencyLookupDialog.class); |
| |
| /** |
| * Hardcoded list of common libraries that we will show in the dialog until the user actually does a search. |
| */ |
| private static final List<Artifact> COMMON_LIBRARIES = ImmutableList.of( |
| new Artifact("com.google.code.gson", "gson", "2.2.4", "GSON"), |
| new Artifact("joda-time", "joda-time", "2.3", "Joda-time"), |
| new Artifact("com.squareup.picasso", "picasso", "2.3.2", "Picasso"), |
| new Artifact("com.squareup", "otto", "1.3.5", "Otto"), |
| new Artifact("org.slf4j", "slf4j-android", "1.7.7", "slf4j"), |
| new Artifact("de.keyboardsurfer.android.widget", "crouton", "1.8.4", "Crouton"), |
| new Artifact("com.nineoldandroids", "library", "2.4.0", "Nine Old Androids"), |
| new Artifact("com.jakewharton", "butterknife", "5.1.1", "Butterknife"), |
| new Artifact("com.google.guava", "guava", "16.0.1", "Guava"), |
| new Artifact("com.squareup.okhttp", "okhttp", "2.0.0", "okhttp"), |
| new Artifact("com.squareup.dagger", "dagger", "1.2.1", "Dagger") |
| ); |
| |
| /** |
| * Hard-coded list of search rewrites to help users find common libraries. |
| */ |
| private static final Map<String, String> SEARCH_OVERRIDES = ImmutableMap.<String, String>builder() |
| .put("jodatime", "joda-time") |
| .put("slf4j", "org.slf4j:slf4j-android") |
| .put("slf4j-android", "org.slf4j:slf4j-android") |
| .put("animation", "com.nineoldandroids:library") |
| .put("pulltorefresh", "com.github.chrisbanes.actionbarpulltorefresh:library") |
| .put("wire", "wire-runtime") |
| .put("tape", "com.squareup:tape") |
| .put("annotations", "androidannotations") |
| .put("svg", "svg-android") |
| .put("commons", "org.apache.commons") |
| .build(); |
| |
| private AsyncProcessIcon myProgressIcon; |
| private TextFieldWithBrowseButton mySearchField; |
| private JTextField mySearchTextField; |
| private JPanel myPanel; |
| private JBList myResultList; |
| private final List<Artifact> myShownItems = Lists.newArrayList(); |
| private final ExecutorService mySearchWorker = Executors.newSingleThreadExecutor(); |
| private final boolean myAndroidModule; |
| |
| private final List<String> myAndroidSdkLibraries = Lists.newArrayList(); |
| |
| /** |
| * Wraps the MavenArtifactInfo and supplies extra descriptive information we can display. |
| */ |
| private static class Artifact extends MavenArtifactInfo { |
| private final String myDescription; |
| |
| public Artifact(@NotNull String groupId, @NotNull String artifactId, @NotNull String version, @Nullable String description) { |
| //noinspection ConstantConditions |
| super(groupId, artifactId, version, null, null, null, null); |
| myDescription = description; |
| } |
| |
| @Nullable |
| public static Artifact fromCoordinate(@NotNull String libraryCoordinate, @Nullable String libraryId) { |
| GradleCoordinate gradleCoordinate = GradleCoordinate.parseCoordinateString(libraryCoordinate); |
| if (gradleCoordinate == null) { |
| return null; |
| } |
| String groupId = gradleCoordinate.getGroupId(); |
| String artifactId = gradleCoordinate.getArtifactId(); |
| if (groupId == null || artifactId == null) { |
| return null; |
| } |
| return new Artifact(groupId, artifactId, gradleCoordinate.getFullRevision(), libraryId); |
| } |
| |
| @NotNull |
| public String toString() { |
| if (myDescription != null) { |
| return myDescription + " (" + getCoordinates() + ")"; |
| } |
| else { |
| return getCoordinates(); |
| } |
| } |
| |
| @NotNull |
| public String getCoordinates() { |
| String version = REVISION_ANY.equals(getVersion()) ? "" : ':' + getVersion(); |
| return getGroupId() + ":" + getArtifactId() + version; |
| } |
| } |
| |
| /** |
| * Comparator for Maven artifacts that does smart ordering for search results based on a given search string |
| */ |
| private static class ArtifactComparator implements Comparator<Artifact> { |
| @NotNull private final String mySearchText; |
| |
| private ArtifactComparator(@NotNull String searchText) { |
| mySearchText = searchText; |
| } |
| |
| @Override |
| public int compare(@NotNull Artifact artifact1, @NotNull Artifact artifact2) { |
| int score = calculateScore(mySearchText, artifact2) - calculateScore(mySearchText, artifact1); |
| if (score != 0) { |
| return score; |
| } |
| else { |
| return artifact2.getVersion().compareTo(artifact1.getVersion()); |
| } |
| } |
| |
| private static int calculateScore(@NotNull String searchText, @NotNull MavenArtifactInfo artifact) { |
| int score = 0; |
| if (artifact.getArtifactId().equals(searchText)) { |
| score++; |
| } |
| if (artifact.getArtifactId().contains(searchText)) { |
| score++; |
| } |
| if (artifact.getGroupId().contains(searchText)) { |
| score++; |
| } |
| return score; |
| } |
| } |
| |
| public MavenDependencyLookupDialog(@NotNull Project project, @Nullable Module module) { |
| super(project, true); |
| myAndroidModule = module != null && AndroidFacet.getInstance(module) != null; |
| myProgressIcon.suspend(); |
| |
| mySearchField.setButtonIcon(AllIcons.Actions.Menu_find); |
| mySearchField.getButton().addActionListener(new ActionListener() { |
| @Override |
| public void actionPerformed(ActionEvent e) { |
| startSearch(); |
| } |
| }); |
| |
| mySearchTextField = mySearchField.getTextField(); |
| mySearchTextField.addActionListener(new ActionListener() { |
| @Override |
| public void actionPerformed(ActionEvent actionEvent) { |
| if (StringUtil.isEmpty(mySearchField.getText())) { |
| return; |
| } |
| if (!isValidCoordinateSelected()) { |
| startSearch(); |
| } |
| else { |
| close(OK_EXIT_CODE); |
| } |
| } |
| }); |
| |
| boolean preview = false; |
| if (module != null) { |
| AndroidFacet facet = AndroidFacet.getInstance(module); |
| if (facet != null) { |
| IdeaAndroidProject androidModel = facet.getAndroidModel(); |
| if (androidModel != null) { |
| ApiVersion minSdkVersion = androidModel.getSelectedVariant().getMergedFlavor().getMinSdkVersion(); |
| if (minSdkVersion != null) { |
| preview = new AndroidVersion(minSdkVersion.getApiLevel(), minSdkVersion.getCodename()).isPreview(); |
| } |
| } |
| } |
| } |
| |
| RepositoryUrlManager manager = RepositoryUrlManager.get(); |
| for (String libraryId : RepositoryUrlManager.EXTRAS_REPOSITORY.keySet()) { |
| String libraryCoordinate = manager.getLibraryCoordinate(libraryId, null, preview); |
| if (libraryCoordinate != null) { |
| Artifact artifact = Artifact.fromCoordinate(libraryCoordinate, libraryId); |
| if (artifact != null) { |
| myAndroidSdkLibraries.add(libraryCoordinate); |
| myShownItems.add(artifact); |
| } |
| } |
| } |
| myShownItems.addAll(COMMON_LIBRARIES); |
| myResultList.setModel(new CollectionComboBoxModel(myShownItems, null)); |
| myResultList.addListSelectionListener(new ListSelectionListener() { |
| @Override |
| public void valueChanged(ListSelectionEvent listSelectionEvent) { |
| Artifact value = (Artifact)myResultList.getSelectedValue(); |
| if (value != null) { |
| mySearchTextField.setText(value.getCoordinates()); |
| } |
| } |
| }); |
| myResultList.addMouseListener(new MouseAdapter() { |
| @Override |
| public void mouseClicked(MouseEvent mouseEvent) { |
| if (mouseEvent.getClickCount() == 2 && isValidCoordinateSelected()) { |
| close(OK_EXIT_CODE); |
| } |
| } |
| }); |
| |
| myOKAction = new OkAction() { |
| @Override |
| protected void doAction(ActionEvent e) { |
| String text = mySearchField.getText(); |
| if (text != null && |
| !hasVersion(text) && |
| RepositoryUrlManager.EXTRAS_REPOSITORY.containsKey(getArtifact(text))) { |
| // If it's a known library that doesn't exist in the local repository, we don't display the version for it. Add it back so that |
| // final string is a valid gradle coordinate. |
| mySearchField.setText(text + ':' + REVISION_ANY); |
| } |
| super.doAction(e); |
| } |
| }; |
| init(); |
| } |
| |
| private static String getArtifact(String coordinate) { |
| int i = coordinate.indexOf(':'); |
| if (i >= 0 && i + 1 < coordinate.length()) { |
| // There's at least one char after the first ':' |
| coordinate = coordinate.substring(i + 1); |
| i = coordinate.indexOf(':'); |
| if (i < 0) { |
| i = coordinate.length(); |
| } |
| return coordinate.substring(0, i); |
| } |
| return null; |
| } |
| |
| @NotNull |
| public String getSearchText() { |
| return mySearchTextField.getText(); |
| } |
| |
| /** |
| * Prepares the search string and initiates the search in a worker thread. |
| */ |
| private void startSearch() { |
| if (myProgressIcon.isRunning()) { |
| return; |
| } |
| myProgressIcon.resume(); |
| synchronized (myShownItems) { |
| myResultList.clearSelection(); |
| myShownItems.clear(); |
| ((CollectionComboBoxModel)myResultList.getModel()).update(); |
| } |
| String text = mySearchTextField.getText(); |
| if (StringUtil.isEmpty(text)) { |
| return; |
| } |
| String override = SEARCH_OVERRIDES.get(text.toLowerCase(Locale.US)); |
| if (override != null) { |
| text = override; |
| } |
| final String finalText = text; |
| mySearchWorker.submit(new Runnable() { |
| @Override |
| public void run() { |
| searchAllRepositories(finalText); |
| } |
| }); |
| } |
| |
| /** |
| * Worker thread body that performs the search against the Maven index and interprets the result set |
| */ |
| private void searchAllRepositories(@NotNull final String text) { |
| try { |
| if (!myProgressIcon.isRunning()) { |
| return; |
| } |
| List<String> results = Lists.newArrayList(); |
| results.addAll(searchMavenCentral(text)); |
| results.addAll(searchSdkRepositories(text)); |
| |
| if (!myProgressIcon.isRunning()) { |
| return; |
| } |
| synchronized (myShownItems) { |
| for (String s : results) { |
| Artifact wrappedArtifact = Artifact.fromCoordinate(s, null); |
| if (!myShownItems.contains(wrappedArtifact)) { |
| myShownItems.add(wrappedArtifact); |
| } |
| } |
| |
| Collections.sort(myShownItems, new ArtifactComparator(text)); |
| |
| // In Android modules, if there are both @aar and @jar versions of the same artifact, hide the @jar one. |
| if (myAndroidModule) { |
| Set<String> itemsToRemove = Sets.newHashSet(); |
| for (Artifact art : myShownItems) { |
| String s = art.getCoordinates(); |
| if (s.endsWith(AAR_PACKAGING)) { |
| itemsToRemove.add(s.replace(AAR_PACKAGING, JAR_PACKAGING)); |
| } |
| } |
| for (Iterator<Artifact> i = myShownItems.iterator(); i.hasNext(); ) { |
| Artifact art = i.next(); |
| if (itemsToRemove.contains(art.getCoordinates())) { |
| i.remove(); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Update the UI in the Swing UI thread |
| */ |
| SwingUtilities.invokeLater(new Runnable() { |
| @Override |
| public void run() { |
| synchronized (myShownItems) { |
| ((CollectionComboBoxModel)myResultList.getModel()).update(); |
| if (myResultList.getSelectedIndex() == -1 && !myShownItems.isEmpty()) { |
| myResultList.setSelectedIndex(0); |
| } |
| if (!myShownItems.isEmpty()) { |
| myResultList.requestFocus(); |
| } |
| } |
| } |
| }); |
| } catch (Exception e) { |
| MavenLog.LOG.error(e); |
| } |
| finally { |
| myProgressIcon.suspend(); |
| } |
| } |
| |
| @NotNull |
| private List<String> searchSdkRepositories(@NotNull String text) { |
| List<String> results = Lists.newArrayList(); |
| for (String library : myAndroidSdkLibraries) { |
| if (library.contains(text)) { |
| results.add(library); |
| } |
| } |
| return results; |
| } |
| |
| @NotNull |
| private static List<String> searchMavenCentral(@NotNull String text) { |
| return HttpRequests.request(String.format(MAVEN_CENTRAL_SEARCH_URL, RESULT_LIMIT, text)) |
| .accept("application/xml") |
| .connect(new HttpRequests.RequestProcessor<List<String>>() { |
| @Override |
| public List<String> process(@NotNull HttpRequests.Request request) throws IOException { |
| try { |
| XPath idPath = XPath.newInstance("str[@name='id']"); |
| XPath versionPath = XPath.newInstance("str[@name='latestVersion']"); |
| //noinspection unchecked |
| List<Element> artifacts = (List<Element>)XPath.newInstance("/response/result/doc").selectNodes(new SAXBuilder().build(request.getReader())); |
| List<String> results = Lists.newArrayListWithExpectedSize(artifacts.size()); |
| for (Element element : artifacts) { |
| try { |
| String id = ((Element)idPath.selectSingleNode(element)).getValue(); |
| results.add(id + ":" + ((Element)versionPath.selectSingleNode(element)).getValue()); |
| } |
| catch (NullPointerException ignored) { |
| // A result is missing an ID or version. Just skip it. |
| } |
| } |
| return results; |
| } |
| catch (JDOMException e) { |
| LOG.error(e); |
| } |
| return Collections.emptyList(); |
| } |
| }, Collections.<String>emptyList(), LOG); |
| } |
| |
| @Override |
| public JComponent getPreferredFocusedComponent() { |
| return mySearchTextField; |
| } |
| |
| @Override |
| @Nullable |
| protected ValidationInfo doValidate() { |
| if (!isValidCoordinateSelected()) { |
| return new ValidationInfo("Please enter a valid coordinate, discover it or select one from the list", getPreferredFocusedComponent()); |
| } |
| return super.doValidate(); |
| } |
| |
| @Override |
| @NotNull |
| protected JComponent createCenterPanel() { |
| return myPanel; |
| } |
| |
| @Override |
| protected void dispose() { |
| Disposer.dispose(myProgressIcon); |
| mySearchWorker.shutdown(); |
| super.dispose(); |
| } |
| |
| @Override |
| @NotNull |
| protected String getDimensionServiceKey() { |
| return MavenDependencyLookupDialog.class.getName(); |
| } |
| |
| private boolean isValidCoordinateSelected() { |
| String text = mySearchTextField.getText(); |
| return GradleCoordinate.parseCoordinateString(text) != null; |
| } |
| |
| private void createUIComponents() { |
| myProgressIcon = new AsyncProcessIcon("Progress"); |
| } |
| |
| public static boolean hasVersion(String coordinateText) { |
| return StringUtil.countChars(coordinateText, ':') > 1; |
| } |
| } |