| /* |
| * 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.android.tools.idea.structure.services; |
| |
| import com.android.tools.idea.gradle.parser.BuildFileStatement; |
| import com.android.tools.idea.gradle.parser.Dependency; |
| import com.android.tools.idea.gradle.parser.GradleBuildFile; |
| import com.android.tools.idea.templates.*; |
| import com.android.tools.idea.templates.parse.SaxUtils; |
| import com.android.tools.idea.templates.recipe.Recipe; |
| import com.android.tools.idea.templates.recipe.RecipeContext; |
| import com.android.tools.idea.ui.properties.ObservableValue; |
| import com.android.tools.idea.ui.properties.collections.ObservableList; |
| import com.android.tools.idea.ui.properties.core.BoolProperty; |
| import com.android.tools.idea.ui.properties.core.IntProperty; |
| import com.android.tools.idea.ui.properties.core.StringProperty; |
| import com.android.tools.idea.ui.properties.expressions.bool.BooleanExpression; |
| import com.android.tools.idea.ui.properties.expressions.integer.IntExpression; |
| import com.android.tools.idea.ui.properties.expressions.string.StringExpression; |
| import com.android.tools.idea.ui.properties.swing.*; |
| import com.google.common.base.Function; |
| import com.google.common.base.Joiner; |
| import com.google.common.base.Splitter; |
| import com.google.common.collect.Lists; |
| import com.intellij.openapi.diagnostic.Logger; |
| import com.intellij.openapi.module.Module; |
| import com.intellij.ui.HyperlinkLabel; |
| import com.intellij.util.containers.Stack; |
| import freemarker.template.Configuration; |
| import freemarker.template.TemplateException; |
| import org.jetbrains.annotations.NotNull; |
| import org.jetbrains.annotations.Nullable; |
| import org.xml.sax.Attributes; |
| import org.xml.sax.SAXException; |
| import org.xml.sax.helpers.DefaultHandler; |
| |
| import javax.swing.*; |
| import javax.xml.bind.JAXBException; |
| import javax.xml.parsers.SAXParser; |
| import java.awt.event.ActionEvent; |
| import java.awt.event.ActionListener; |
| import java.io.File; |
| import java.io.IOException; |
| import java.io.StringReader; |
| import java.net.URI; |
| import java.net.URISyntaxException; |
| import java.util.Arrays; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| import static com.google.common.base.CaseFormat.UPPER_CAMEL; |
| import static com.google.common.base.CaseFormat.UPPER_UNDERSCORE; |
| |
| /** |
| * Class which handles the parsing of a service.xml file. Note that a service.xml file is |
| * additionally associated with a {@link Module}, and the same service.xml file may parse |
| * differently depending on the settings of the module it is linked to. |
| * <p/> |
| * This class inherits from SAX's {@link DefaultHandler} and is meant to be passed into a |
| * {@link SAXParser}. |
| * <p/> |
| * See {@link Schema} for the full XML schema for a service file to get a better overview of its |
| * capabilities. |
| */ |
| /* package */ final class ServiceXmlParser extends DefaultHandler { |
| private static final Logger LOG = Logger.getInstance(ServiceXmlParser.class); |
| |
| /** |
| * Highest supported format; templates with a higher number will be skipped |
| */ |
| private static final int CURRENT_FORMAT = 1; |
| |
| /** |
| * Searches for values that look like "${var_name}". The variable name is captured. |
| */ |
| private static final Pattern VAR_PATTERN = Pattern.compile("^\\$\\{(.+)\\}$"); |
| |
| /** |
| * Searches for values that look like "${method_name()}". The method name is captured. |
| */ |
| private static final Pattern ACTION_PATTERN = Pattern.compile("^\\$\\{(.+)\\(\\)\\}$"); |
| |
| @NotNull private final Module myModule; |
| @NotNull private final File myRootPath; |
| @NotNull private final ServiceContext myContext; |
| @NotNull private final Stack<String> myTagStack = new Stack<String>(); |
| |
| @NotNull private ServicePanelBuilder myPanelBuilder; |
| @NotNull private ServiceCategory myServiceCategory; |
| @NotNull private DeveloperServiceMetadata myDeveloperServiceMetadata; |
| @NotNull private File myRecipeFile; |
| |
| public ServiceXmlParser(@NotNull Module module, @NotNull File rootPath, @NotNull ServiceContext serviceContext) { |
| myModule = module; |
| myRootPath = rootPath; |
| myContext = serviceContext; |
| myPanelBuilder = new ServicePanelBuilder(); |
| } |
| |
| @NotNull |
| private static URI toUri(@NotNull String urlString) { |
| try { |
| return new URI(urlString); |
| } |
| catch (URISyntaxException e) { |
| throw new RuntimeException(String.format("Malformed link argument: %1$s", urlString), e); |
| } |
| } |
| |
| @NotNull |
| public Module getModule() { |
| return myModule; |
| } |
| |
| @NotNull |
| public ServiceContext getContext() { |
| return myContext; |
| } |
| |
| public DeveloperServiceMetadata getDeveloperServiceMetadata() { |
| return myDeveloperServiceMetadata; |
| } |
| |
| public ServiceCategory getServiceCategory() { |
| return myServiceCategory; |
| } |
| |
| public JPanel getServicePanel() { |
| return myPanelBuilder.getPanel(); |
| } |
| |
| @Override |
| public void startElement(String uri, String localName, @NotNull String tagName, @NotNull Attributes attributes) throws SAXException { |
| myTagStack.push(tagName); |
| if (tagName.equals(Schema.Service.TAG)) { |
| parseServiceTag(attributes); |
| } |
| else if (tagName.equals(Schema.UiGrid.TAG)) { |
| parseUiGridTag(attributes); |
| } |
| else if (tagName.equals(Schema.UiButton.TAG)) { |
| parseUiButton(attributes); |
| } |
| else if (tagName.equals(Schema.UiCheckbox.TAG)) { |
| parseUiCheckbox(attributes); |
| } |
| else if (tagName.equals(Schema.UiInput.TAG)) { |
| parseUiInput(attributes); |
| } |
| else if (tagName.equals(Schema.UiLabel.TAG)) { |
| parseUiLabel(attributes); |
| } |
| else if (tagName.equals(Schema.UiLink.TAG)) { |
| parseUiLink(attributes); |
| } |
| else if (tagName.equals(Schema.UiPulldown.TAG)) { |
| parseUiPulldown(attributes); |
| } |
| else { |
| LOG.warn("WARNING: Unknown service directive " + tagName); |
| } |
| } |
| |
| @Override |
| public void endElement(String uri, String localName, @NotNull String tagName) throws SAXException { |
| if (tagName.equals(Schema.Service.TAG)) { |
| closeServiceTag(); |
| } |
| else if (tagName.equals(Schema.UiGrid.TAG)) { |
| closeUiGridTag(); |
| } |
| |
| myTagStack.pop(); |
| } |
| |
| @NotNull |
| public Recipe createRecipe(boolean executeRecipe) { |
| Configuration freemarker = new FreemarkerConfiguration(); |
| PrefixTemplateLoader loader = new PrefixTemplateLoader(myRootPath.getPath()); |
| Map<String, Object> paramMap = FreemarkerUtils.createParameterMap(myContext.toValueMap()); |
| |
| try { |
| freemarker.setTemplateLoader(loader); |
| String xml = FreemarkerUtils.processFreemarkerTemplate(freemarker, paramMap, myRecipeFile); |
| Recipe recipe = Recipe.parse(new StringReader(xml)); |
| |
| if (executeRecipe) { |
| RecipeContext recipeContext = new RecipeContext(myModule, loader, freemarker, paramMap, myRootPath, false); |
| recipe.execute(recipeContext); |
| |
| // Convert relative paths to absolute paths, so TemplateUtils.openEditors can find them |
| List<File> relFilesToOpen = recipe.getFilesToOpen(); |
| List<File> absFilesToOpen = Lists.newArrayListWithCapacity(relFilesToOpen.size()); |
| for (File file : relFilesToOpen) { |
| absFilesToOpen.add(recipeContext.getTargetFile(file)); |
| } |
| TemplateUtils.openEditors(myModule.getProject(), absFilesToOpen, true); |
| } |
| |
| return recipe; |
| } |
| catch (TemplateException e) { |
| throw new RuntimeException(e); |
| } |
| catch (JAXBException e) { |
| throw new RuntimeException(e); |
| } |
| catch (IOException e) { |
| throw new RuntimeException(e); |
| } |
| } |
| |
| private void parseServiceTag(@NotNull Attributes attributes) { |
| String format = requireAttr(attributes, Schema.Service.ATTR_FORMAT); |
| try { |
| if (Integer.parseInt(format) > CURRENT_FORMAT) { |
| throw new RuntimeException( |
| String.format("Definition found with format %1$s newer than newest known format %2$s", format, CURRENT_FORMAT)); |
| } |
| } |
| catch (NumberFormatException e) { |
| throw new RuntimeException(String.format("Non-numeric value passed to format attribute: %1$s", format)); |
| } |
| |
| String name = requireAttr(attributes, Schema.Service.ATTR_NAME); |
| String description = requireAttr(attributes, Schema.Service.ATTR_DESCRIPTION); |
| String category = requireAttr(attributes, Schema.Service.ATTR_CATEGORY); |
| File iconFile = new File(myRootPath, requireAttr(attributes, Schema.Service.ATTR_ICON)); |
| |
| String learnLink = attributes.getValue(Schema.Service.ATTR_LEARN_MORE); |
| String apiLink = attributes.getValue(Schema.Service.ATTR_API_DOCS); |
| |
| myRecipeFile = new File(myRootPath, requireAttr(attributes, Schema.Service.ATTR_EXECUTE)); |
| |
| try { |
| myServiceCategory = ServiceCategory.valueOf(UPPER_CAMEL.to(UPPER_UNDERSCORE, category)); |
| } |
| catch (IllegalArgumentException e) { |
| // We got a bad category value - show the developer an error so they can fix their service.xml |
| List<String> validCategories = Lists.transform(Arrays.asList(ServiceCategory.values()), new Function<ServiceCategory, String>() { |
| @Nullable |
| @Override |
| public String apply(ServiceCategory c) { |
| return c.getDisplayName(); |
| } |
| }); |
| |
| throw new RuntimeException( |
| String.format("Invalid category \"%1$s\", should be one of [%2$s]", category, Joiner.on(',').join(validCategories))); |
| } |
| |
| Icon icon = new ImageIcon(iconFile.getPath()); |
| myDeveloperServiceMetadata = new DeveloperServiceMetadata(name, description, icon); |
| if (learnLink != null) { |
| myDeveloperServiceMetadata.setLearnMoreLink(toUri(learnLink)); |
| } |
| if (apiLink != null) { |
| myDeveloperServiceMetadata.setApiLink(toUri(apiLink)); |
| } |
| } |
| |
| private void closeServiceTag() { |
| Recipe recipe = createRecipe(false); |
| |
| for (String d : recipe.getDependencies()) { |
| myDeveloperServiceMetadata.addDependency(d); |
| } |
| for (File f : recipe.getFilesToModify()) { |
| myDeveloperServiceMetadata.addModifiedFile(f); |
| } |
| |
| // Consider ourselves installed if this service's dependencies are already found in the current |
| // module. |
| // TODO: Flesh this simplistic approach out more. We would like to have a way to say a service |
| // isn't installed even if its dependency happens to be added to the project. For example, |
| // multiple services might share a dependency but have additional settings that indicate some |
| // are installed and others aren't. |
| List<String> moduleDependencyNames = Lists.newArrayList(); |
| GradleBuildFile gradleBuildFile = GradleBuildFile.get(myModule); |
| if (gradleBuildFile != null) { |
| for (BuildFileStatement dependency : gradleBuildFile.getDependencies()) { |
| if (dependency instanceof Dependency) { |
| Object data = ((Dependency)dependency).data; |
| if (data instanceof String) { |
| String dependencyString = (String)data; |
| List<String> dependencyParts = Lists.newArrayList(Splitter.on(':').split(dependencyString)); |
| if (dependencyParts.size() == 3) { |
| // From the dependency URL "group:name:version" string - we only care about "name" |
| // We ignore the version, as a service may be installed using an older version |
| // TODO: Handle "group: 'com.android.support', name: 'support-v4', version: '21.0.+'" format also |
| // See also GradleDetector#getNamedDependency |
| moduleDependencyNames.add(dependencyParts.get(1)); |
| } |
| } |
| } |
| } |
| } |
| boolean allDependenciesFound = true; |
| for (String serviceDependency : myDeveloperServiceMetadata.getDependencies()) { |
| boolean thisDependencyFound = false; |
| for (String moduleDependencyName : moduleDependencyNames) { |
| if (serviceDependency.contains(moduleDependencyName)) { |
| thisDependencyFound = true; |
| break; |
| } |
| } |
| |
| if (!thisDependencyFound) { |
| allDependenciesFound = false; |
| break; |
| } |
| } |
| |
| myContext.installed().set(allDependenciesFound); |
| myContext.snapshot(); |
| } |
| |
| private void parseUiGridTag(@NotNull Attributes attributes) { |
| parseRowCol(attributes); |
| |
| String weights = requireAttr(attributes, Schema.UiGrid.ATTR_COL_DEFINITIONS); |
| JPanel grid = myPanelBuilder.startGrid(weights); |
| bindTopLevelProperties(grid, attributes); |
| } |
| |
| private void closeUiGridTag() { |
| myPanelBuilder.endGrid(); |
| } |
| |
| private void parseUiButton(@NotNull Attributes attributes) { |
| parseRowCol(attributes); |
| JButton button = myPanelBuilder.addButton(); |
| bindTopLevelProperties(button, attributes); |
| |
| String textKey = attributes.getValue(Schema.UiButton.ATTR_TEXT); |
| if (textKey != null) { |
| TextProperty textProperty = new TextProperty(button); |
| myPanelBuilder.getBindings().bind(textProperty, parseString(textKey)); |
| } |
| |
| String actionKey = attributes.getValue(Schema.UiButton.ATTR_ACTION); |
| if (actionKey != null) { |
| final Runnable action = parseAction(actionKey); |
| button.addActionListener(new ActionListener() { |
| @Override |
| public void actionPerformed(ActionEvent e) { |
| action.run(); |
| } |
| }); |
| } |
| } |
| |
| private void parseUiCheckbox(@NotNull Attributes attributes) { |
| parseRowCol(attributes); |
| JCheckBox checkbox = myPanelBuilder.addCheckbox(); |
| bindTopLevelProperties(checkbox, attributes); |
| |
| String textKey = attributes.getValue(Schema.UiCheckbox.ATTR_TEXT); |
| if (textKey != null) { |
| TextProperty textProperty = new TextProperty(checkbox); |
| myPanelBuilder.getBindings().bind(textProperty, parseString(textKey)); |
| } |
| |
| String checkedKey = attributes.getValue(Schema.UiCheckbox.ATTR_CHECKED); |
| if (checkedKey != null) { |
| SelectedProperty selectedProperty = new SelectedProperty(checkbox); |
| BoolProperty checkedValue = (BoolProperty)parseBool(checkedKey); |
| myPanelBuilder.getBindings().bindTwoWay(selectedProperty, checkedValue); |
| } |
| } |
| |
| private void parseUiInput(@NotNull Attributes attributes) { |
| parseRowCol(attributes); |
| JTextField field = myPanelBuilder.addField(); |
| bindTopLevelProperties(field, attributes); |
| |
| String textKey = attributes.getValue(Schema.UiInput.ATTR_TEXT); |
| if (textKey != null) { |
| TextProperty textProperty = new TextProperty(field); |
| StringProperty textValue = (StringProperty)parseString(textKey); |
| myPanelBuilder.getBindings().bindTwoWay(textProperty, textValue); |
| } |
| } |
| |
| private void parseUiLabel(@NotNull Attributes attributes) { |
| parseRowCol(attributes); |
| JLabel label = myPanelBuilder.addLabel(); |
| bindTopLevelProperties(label, attributes); |
| |
| String textKey = attributes.getValue(Schema.UiLabel.ATTR_TEXT); |
| if (textKey != null) { |
| TextProperty textProperty = new TextProperty(label); |
| myPanelBuilder.getBindings().bind(textProperty, parseString(textKey)); |
| } |
| } |
| private void parseUiLink(@NotNull Attributes attributes) { |
| parseRowCol(attributes); |
| HyperlinkLabel link = myPanelBuilder.addLink(requireAttr(attributes, Schema.UiLink.ATTR_TEXT), |
| toUri(requireAttr(attributes, Schema.UiLink.ATTR_URL))); |
| bindTopLevelProperties(link, attributes); |
| } |
| private void parseUiPulldown(@NotNull Attributes attributes) { |
| parseRowCol(attributes); |
| String listKey = requireAttr(attributes, Schema.UiPulldown.ATTR_LIST); |
| ObservableList<String> backingList = getList(listKey); |
| JComboBox comboBox = myPanelBuilder.addComboBox(backingList); |
| bindTopLevelProperties(comboBox, attributes); |
| |
| String indexKey = attributes.getValue(Schema.UiPulldown.ATTR_INDEX); |
| if (indexKey != null) { |
| SelectedIndexProperty indexProperty = new SelectedIndexProperty(comboBox); |
| IntProperty indexValue = (IntProperty)parseInt(indexKey); |
| myPanelBuilder.getBindings().bindTwoWay(indexProperty, indexValue); |
| } |
| } |
| |
| private void parseRowCol(@NotNull Attributes attributes) { |
| String row = attributes.getValue(Schema.UiTag.ATTR_ROW); |
| if (row != null) { |
| myPanelBuilder.setRow(Integer.parseInt(row)); |
| } |
| String col = attributes.getValue(Schema.UiTag.ATTR_COL); |
| if (col != null) { |
| myPanelBuilder.setCol(Integer.parseInt(col)); |
| } |
| } |
| |
| private void bindTopLevelProperties(@NotNull JComponent component, @NotNull Attributes attributes) { |
| String visibleKey = attributes.getValue(Schema.UiTag.ATTR_VISIBLE); |
| if (visibleKey != null) { |
| VisibleProperty visibleProperty = new VisibleProperty(component); |
| myPanelBuilder.getBindings().bind(visibleProperty, parseBool(visibleKey)); |
| } |
| |
| String enabledKey = attributes.getValue(Schema.UiTag.ATTR_ENABLED); |
| if (enabledKey != null) { |
| EnabledProperty enabledProperty = new EnabledProperty(component); |
| myPanelBuilder.getBindings().bind(enabledProperty, parseBool(enabledKey)); |
| } |
| } |
| |
| @NotNull |
| private Runnable parseAction(String value) { |
| Matcher matcher = ACTION_PATTERN.matcher(value); |
| if (matcher.find()) { |
| String varName = matcher.group(1); |
| return myContext.getAction(varName); |
| } |
| else { |
| throw new RuntimeException("Invalid action value (did you forget ${...()}): " + value); |
| } |
| } |
| |
| @NotNull |
| private ObservableValue<Boolean> parseBool(@NotNull final String value) { |
| Matcher matcher = VAR_PATTERN.matcher(value); |
| if (matcher.find()) { |
| String varName = matcher.group(1); |
| return (ObservableValue<Boolean>)myContext.getValue(varName); |
| } |
| else { |
| final Boolean boolValue = (Boolean)TypedVariable.parse(TypedVariable.Type.BOOLEAN, value); |
| if (boolValue == null) { |
| throw new RuntimeException("Invalid bool value (did you forget ${...}): " + value); |
| } |
| return new BooleanExpression() { |
| @NotNull |
| @Override |
| public Boolean get() { |
| return boolValue; |
| } |
| }; |
| } |
| } |
| |
| @NotNull |
| private ObservableValue<String> parseString(@NotNull final String value) { |
| Matcher matcher = VAR_PATTERN.matcher(value); |
| if (matcher.find()) { |
| String varName = matcher.group(1); |
| return (ObservableValue<String>)myContext.getValue(varName); |
| } |
| else { |
| return new StringExpression() { |
| @NotNull |
| @Override |
| public String get() { |
| return value; |
| } |
| }; |
| } |
| } |
| |
| @NotNull |
| private ObservableValue<Integer> parseInt(@NotNull final String value) { |
| Matcher matcher = VAR_PATTERN.matcher(value); |
| if (matcher.find()) { |
| String varName = matcher.group(1); |
| return (ObservableValue<Integer>)myContext.getValue(varName); |
| } |
| else { |
| final Integer intValue = (Integer)TypedVariable.parse(TypedVariable.Type.INTEGER, value); |
| if (intValue == null) { |
| throw new RuntimeException("Invalid integer value (did you forget ${...}): " + value); |
| } |
| |
| return new IntExpression() { |
| @NotNull |
| @Override |
| public Integer get() { |
| return intValue; |
| } |
| }; |
| } |
| } |
| |
| @NotNull |
| private <E> ObservableList<E> getList(@NotNull final String value) { |
| Matcher matcher = VAR_PATTERN.matcher(value); |
| if (matcher.find()) { |
| String varName = matcher.group(1); |
| return (ObservableList<E>)myContext.getValue(varName); |
| } |
| else { |
| throw new RuntimeException("Invalid list value (did you forget ${...}): " + value); |
| } |
| } |
| |
| private String requireAttr(@NotNull Attributes attributes, @NotNull String attrFormat) { |
| return SaxUtils.requireAttr(myTagStack.peek(), attributes, attrFormat); |
| } |
| |
| private static final class Schema { |
| public static final class Service { |
| public static final String TAG = "service"; |
| public static final String ATTR_API_DOCS = "apiDocs"; |
| public static final String ATTR_CATEGORY = "category"; |
| public static final String ATTR_DESCRIPTION = "description"; |
| public static final String ATTR_EXECUTE = "execute"; |
| public static final String ATTR_FORMAT = "format"; |
| public static final String ATTR_ICON = "icon"; |
| public static final String ATTR_LEARN_MORE = "learnMore"; |
| public static final String ATTR_MIN_API = "minApi"; |
| public static final String ATTR_NAME = "name"; |
| } |
| |
| public static abstract class UiTag { |
| public static final String ATTR_COL = "col"; |
| public static final String ATTR_ROW = "row"; |
| public static final String ATTR_ENABLED = "enabled"; |
| public static final String ATTR_VISIBLE = "visible"; |
| } |
| |
| public static final class UiGrid extends UiTag { |
| public static final String TAG = "uiGrid"; |
| public static final String ATTR_COL_DEFINITIONS = "colDefinitions"; |
| } |
| |
| public static final class UiButton extends UiTag { |
| public static final String TAG = "uiButton"; |
| public static final String ATTR_TEXT = "text"; |
| public static final String ATTR_ACTION = "action"; |
| } |
| |
| public static final class UiCheckbox extends UiTag { |
| public static final String TAG = "uiCheckbox"; |
| public static final String ATTR_TEXT = "text"; |
| public static final String ATTR_CHECKED = "checked"; |
| } |
| |
| public static final class UiInput extends UiTag { |
| public static final String TAG = "uiInput"; |
| public static final String ATTR_TEXT = "text"; |
| } |
| |
| public static final class UiLabel extends UiTag { |
| public static final String TAG = "uiLabel"; |
| public static final String ATTR_TEXT = "text"; |
| } |
| |
| public static final class UiLink extends UiTag { |
| public static final String TAG = "uiLink"; |
| public static final String ATTR_TEXT = "text"; |
| public static final String ATTR_URL = "url"; |
| } |
| |
| public static final class UiPulldown extends UiTag { |
| public static final String TAG = "uiPulldown"; |
| public static final String ATTR_LIST = "list"; |
| public static final String ATTR_INDEX = "index"; |
| } |
| } |
| } |