| /* |
| * 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.sdk.remote.internal; |
| |
| import com.android.annotations.VisibleForTesting; |
| import com.android.annotations.VisibleForTesting.Visibility; |
| import com.android.io.NonClosingInputStream; |
| import com.android.io.NonClosingInputStream.CloseBehavior; |
| import com.android.tools.idea.sdk.remote.internal.sources.SdkAddonsListConstants; |
| import com.android.tools.idea.sdk.remote.internal.sources.SdkRepoConstants; |
| import org.w3c.dom.Document; |
| import org.w3c.dom.NamedNodeMap; |
| import org.w3c.dom.Node; |
| import org.xml.sax.ErrorHandler; |
| import org.xml.sax.InputSource; |
| import org.xml.sax.SAXException; |
| import org.xml.sax.SAXParseException; |
| |
| import javax.net.ssl.SSLKeyException; |
| import javax.xml.XMLConstants; |
| import javax.xml.parsers.DocumentBuilder; |
| import javax.xml.parsers.DocumentBuilderFactory; |
| import javax.xml.parsers.ParserConfigurationException; |
| import javax.xml.transform.stream.StreamSource; |
| import javax.xml.validation.Schema; |
| import javax.xml.validation.SchemaFactory; |
| import javax.xml.validation.Validator; |
| import java.io.*; |
| import java.net.UnknownHostException; |
| import java.util.ArrayList; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| /** |
| * Fetches and loads an sdk-addons-list XML. |
| * <p/> |
| * Such an XML contains a simple list of add-ons site that are to be loaded by default by the |
| * SDK Manager. <br/> |
| * The XML must conform to the sdk-addons-list-N.xsd. <br/> |
| * Constants used in the XML are defined in {@link SdkAddonsListConstants}. |
| */ |
| public class AddonsListFetcher { |
| |
| public enum SiteType { |
| ADDON_SITE, |
| SYS_IMG_SITE |
| } |
| |
| /** |
| * An immutable structure representing an add-on site. |
| */ |
| public static class Site { |
| private final String mUrl; |
| private final String mUiName; |
| private final SiteType mType; |
| |
| private Site(String url, String uiName, SiteType type) { |
| mType = type; |
| mUrl = url.trim(); |
| mUiName = uiName; |
| } |
| |
| public String getUrl() { |
| return mUrl; |
| } |
| |
| public String getUiName() { |
| return mUiName; |
| } |
| |
| public SiteType getType() { |
| return mType; |
| } |
| |
| /** Returns a debug string representation of this object. Not for user display. */ |
| @Override |
| public String toString() { |
| return String.format("<%1$s URL='%2$s' Name='%3$s'>", //$NON-NLS-1$ |
| mType, mUrl, mUiName); |
| } |
| } |
| |
| /** |
| * Fetches the addons list from the given URL. |
| * |
| * @param url The URL of an XML file resource that conforms to the latest sdk-addons-list-N.xsd. |
| * For the default operation, use {@link SdkAddonsListConstants#URL_ADDON_LIST}. |
| * Cannot be null. |
| * @param cache The {@link DownloadCache} instance to use. Cannot be null. |
| * @param monitor A monitor to report errors. Cannot be null. |
| * @return An array of {@link Site} on success (possibly empty), or null on error. |
| */ |
| public Site[] fetch(String url, DownloadCache cache, ITaskMonitor monitor) { |
| |
| url = url == null ? "" : url.trim(); |
| |
| monitor.setProgressMax(6); |
| monitor.setDescription("Fetching %1$s", url); |
| monitor.incProgress(1); |
| |
| Exception[] exception = new Exception[] { null }; |
| Boolean[] validatorFound = new Boolean[] { Boolean.FALSE }; |
| String[] validationError = new String[] { null }; |
| Document validatedDoc = null; |
| String validatedUri = null; |
| |
| String[] defaultNames = new String[SdkAddonsListConstants.NS_LATEST_VERSION]; |
| for (int version = SdkAddonsListConstants.NS_LATEST_VERSION, i = 0; |
| version >= 1; |
| version--, i++) { |
| defaultNames[i] = SdkAddonsListConstants.getDefaultName(version); |
| } |
| |
| InputStream xml = fetchXmlUrl(url, cache, monitor.createSubMonitor(1), exception); |
| if (xml != null) { |
| int version = getXmlSchemaVersion(xml); |
| if (version == 0) { |
| closeStream(xml); |
| xml = null; |
| } |
| } |
| |
| String baseUrl = url; |
| if (!baseUrl.endsWith("/")) { //$NON-NLS-1$ |
| int pos = baseUrl.lastIndexOf('/'); |
| if (pos > 0) { |
| baseUrl = baseUrl.substring(0, pos + 1); |
| } |
| } |
| |
| // If we can't find the latest version, try earlier schema versions. |
| if (xml == null && defaultNames.length > 0) { |
| ITaskMonitor subMonitor = monitor.createSubMonitor(1); |
| subMonitor.setProgressMax(defaultNames.length); |
| |
| for (String name : defaultNames) { |
| String newUrl = baseUrl + name; |
| if (newUrl.equals(url)) { |
| continue; |
| } |
| xml = fetchXmlUrl(newUrl, cache, subMonitor.createSubMonitor(1), exception); |
| if (xml != null) { |
| int version = getXmlSchemaVersion(xml); |
| if (version == 0) { |
| closeStream(xml); |
| xml = null; |
| } else { |
| url = newUrl; |
| subMonitor.incProgress( |
| subMonitor.getProgressMax() - subMonitor.getProgress()); |
| break; |
| } |
| } |
| } |
| } else { |
| monitor.incProgress(1); |
| } |
| |
| if (xml != null) { |
| monitor.setDescription("Validate XML"); |
| |
| // Explore the XML to find the potential XML schema version |
| int version = getXmlSchemaVersion(xml); |
| |
| if (version >= 1 && version <= SdkAddonsListConstants.NS_LATEST_VERSION) { |
| // This should be a version we can handle. Try to validate it |
| // and report any error as invalid XML syntax, |
| |
| String uri = validateXml(xml, url, version, validationError, validatorFound); |
| if (uri != null) { |
| // Validation was successful |
| validatedDoc = getDocument(xml, monitor); |
| validatedUri = uri; |
| |
| } |
| } else if (version > SdkAddonsListConstants.NS_LATEST_VERSION) { |
| // The schema used is more recent than what is supported by this tool. |
| // We don't have an upgrade-path support yet, so simply ignore the document. |
| closeStream(xml); |
| return null; |
| } |
| } |
| |
| // If any exception was handled during the URL fetch, display it now. |
| if (exception[0] != null) { |
| String reason = null; |
| if (exception[0] instanceof FileNotFoundException) { |
| // FNF has no useful getMessage, so we need to special handle it. |
| reason = "File not found"; |
| } else if (exception[0] instanceof UnknownHostException && |
| exception[0].getMessage() != null) { |
| // This has no useful getMessage yet could really use one |
| reason = String.format("Unknown Host %1$s", exception[0].getMessage()); |
| } else if (exception[0] instanceof SSLKeyException) { |
| // That's a common error and we have a pref for it. |
| reason = "HTTPS SSL error. You might want to force download through HTTP in the settings."; |
| } else if (exception[0].getMessage() != null) { |
| reason = exception[0].getMessage(); |
| } else { |
| // We don't know what's wrong. Let's give the exception class at least. |
| reason = String.format("Unknown (%1$s)", exception[0].getClass().getName()); |
| } |
| |
| monitor.logError("Failed to fetch URL %1$s, reason: %2$s", url, reason); |
| } |
| |
| if (validationError[0] != null) { |
| monitor.logError("%s", validationError[0]); //$NON-NLS-1$ |
| } |
| |
| // Stop here if we failed to validate the XML. We don't want to load it. |
| if (validatedDoc == null) { |
| closeStream(xml); |
| return null; |
| } |
| |
| monitor.incProgress(1); |
| |
| Site[] result = null; |
| |
| if (xml != null) { |
| monitor.setDescription("Parse XML"); |
| monitor.incProgress(1); |
| result = parseAddonsList(validatedDoc, validatedUri, baseUrl, monitor); |
| } |
| |
| // done |
| monitor.incProgress(1); |
| |
| closeStream(xml); |
| return result; |
| } |
| |
| /** |
| * Fetches the document at the given URL and returns it as a stream. Returns |
| * null if anything wrong happens. |
| * |
| * @param urlString The URL to load, as a string. |
| * @param monitor {@link ITaskMonitor} related to this URL. |
| * @param outException If non null, where to store any exception that |
| * happens during the fetch. |
| */ |
| private InputStream fetchXmlUrl(String urlString, |
| DownloadCache cache, |
| ITaskMonitor monitor, |
| Exception[] outException) { |
| try { |
| InputStream xml = cache.openCachedUrl(urlString, monitor); |
| if (xml != null) { |
| xml.mark(500000); |
| xml = new NonClosingInputStream(xml); |
| ((NonClosingInputStream) xml).setCloseBehavior(CloseBehavior.RESET); |
| } |
| return xml; |
| } catch (Exception e) { |
| if (outException != null) { |
| outException[0] = e; |
| } |
| } |
| |
| return null; |
| } |
| |
| /** |
| * Closes the stream, ignore any exception from InputStream.close(). |
| * If the stream is a NonClosingInputStream, sets it to CloseBehavior.CLOSE first. |
| */ |
| private void closeStream(InputStream is) { |
| if (is != null) { |
| if (is instanceof NonClosingInputStream) { |
| ((NonClosingInputStream) is).setCloseBehavior(CloseBehavior.CLOSE); |
| } |
| try { |
| is.close(); |
| } catch (IOException ignore) {} |
| } |
| } |
| |
| /** |
| * Manually parses the root element of the XML to extract the schema version |
| * at the end of the xmlns:sdk="http://schemas.android.com/sdk/android/addons-list/$N" |
| * declaration. |
| * |
| * @return 1..{@link SdkAddonsListConstants#NS_LATEST_VERSION} for a valid schema version |
| * or 0 if no schema could be found. |
| */ |
| @VisibleForTesting(visibility=Visibility.PRIVATE) |
| protected int getXmlSchemaVersion(InputStream xml) { |
| if (xml == null) { |
| return 0; |
| } |
| |
| // Get an XML document |
| Document doc = null; |
| try { |
| assert xml.markSupported(); |
| xml.reset(); |
| |
| DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); |
| factory.setIgnoringComments(false); |
| factory.setValidating(false); |
| |
| // Parse the old document using a non namespace aware builder |
| factory.setNamespaceAware(false); |
| DocumentBuilder builder = factory.newDocumentBuilder(); |
| |
| // We don't want the default handler which prints errors to stderr. |
| builder.setErrorHandler(new ErrorHandler() { |
| @Override |
| public void warning(SAXParseException e) throws SAXException { |
| // pass |
| } |
| @Override |
| public void fatalError(SAXParseException e) throws SAXException { |
| throw e; |
| } |
| @Override |
| public void error(SAXParseException e) throws SAXException { |
| throw e; |
| } |
| }); |
| |
| doc = builder.parse(xml); |
| |
| // Prepare a new document using a namespace aware builder |
| factory.setNamespaceAware(true); |
| builder = factory.newDocumentBuilder(); |
| |
| } catch (Exception e) { |
| // Failed to reset XML stream |
| // Failed to get builder factor |
| // Failed to create XML document builder |
| // Failed to parse XML document |
| // Failed to read XML document |
| //--For debug--System.err.println("getXmlSchemaVersion exception: " + e.toString()); |
| } |
| |
| if (doc == null) { |
| return 0; |
| } |
| |
| // Check the root element is an XML with at least the following properties: |
| // <sdk:sdk-addons-list |
| // xmlns:sdk="http://schemas.android.com/sdk/android/addons-list/$N"> |
| // |
| // Note that we don't have namespace support enabled, we just do it manually. |
| |
| Pattern nsPattern = Pattern.compile(SdkAddonsListConstants.NS_PATTERN); |
| |
| String prefix = null; |
| for (Node child = doc.getFirstChild(); child != null; child = child.getNextSibling()) { |
| if (child.getNodeType() == Node.ELEMENT_NODE) { |
| prefix = null; |
| String name = child.getNodeName(); |
| int pos = name.indexOf(':'); |
| if (pos > 0 && pos < name.length() - 1) { |
| prefix = name.substring(0, pos); |
| name = name.substring(pos + 1); |
| } |
| if (SdkAddonsListConstants.NODE_SDK_ADDONS_LIST.equals(name)) { |
| NamedNodeMap attrs = child.getAttributes(); |
| String xmlns = "xmlns"; //$NON-NLS-1$ |
| if (prefix != null) { |
| xmlns += ":" + prefix; //$NON-NLS-1$ |
| } |
| Node attr = attrs.getNamedItem(xmlns); |
| if (attr != null) { |
| String uri = attr.getNodeValue(); |
| if (uri != null) { |
| Matcher m = nsPattern.matcher(uri); |
| if (m.matches()) { |
| String version = m.group(1); |
| try { |
| return Integer.parseInt(version); |
| } catch (NumberFormatException e) { |
| return 0; |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| return 0; |
| } |
| |
| /** |
| * Validates this XML against one of the requested SDK Repository schemas. |
| * If the XML was correctly validated, returns the schema that worked. |
| * If it doesn't validate, returns null and stores the error in outError[0]. |
| * If we can't find a validator, returns null and set validatorFound[0] to false. |
| */ |
| @VisibleForTesting(visibility=Visibility.PRIVATE) |
| protected String validateXml(InputStream xml, String url, int version, |
| String[] outError, Boolean[] validatorFound) { |
| |
| if (xml == null) { |
| return null; |
| } |
| |
| try { |
| Validator validator = getValidator(version); |
| |
| if (validator == null) { |
| validatorFound[0] = Boolean.FALSE; |
| outError[0] = String.format( |
| "XML verification failed for %1$s.\nNo suitable XML Schema Validator could be found in your Java environment. Please consider updating your version of Java.", |
| url); |
| return null; |
| } |
| |
| validatorFound[0] = Boolean.TRUE; |
| |
| // Reset the stream if it supports that operation. |
| assert xml.markSupported(); |
| xml.reset(); |
| |
| // Validation throws a bunch of possible Exceptions on failure. |
| validator.validate(new StreamSource(xml)); |
| return SdkAddonsListConstants.getSchemaUri(version); |
| |
| } catch (SAXParseException e) { |
| outError[0] = String.format( |
| "XML verification failed for %1$s.\nLine %2$d:%3$d, Error: %4$s", |
| url, |
| e.getLineNumber(), |
| e.getColumnNumber(), |
| e.toString()); |
| |
| } catch (Exception e) { |
| outError[0] = String.format( |
| "XML verification failed for %1$s.\nError: %2$s", |
| url, |
| e.toString()); |
| } |
| return null; |
| } |
| |
| /** |
| * Helper method that returns a validator for our XSD, or null if the current Java |
| * implementation can't process XSD schemas. |
| * |
| * @param version The version of the XML Schema. |
| * See {@link SdkAddonsListConstants#getXsdStream(int)} |
| */ |
| private Validator getValidator(int version) throws SAXException { |
| StreamSource[] xsdStreams = SdkAddonsListConstants.getXsdStream(version); |
| SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); |
| |
| if (factory == null) { |
| return null; |
| } |
| |
| // This may throw a SAX Exception if the schema itself is not a valid XSD |
| Schema schema = factory.newSchema(xsdStreams); |
| |
| Validator validator = schema == null ? null : schema.newValidator(); |
| |
| return validator; |
| } |
| |
| /** |
| * Takes an XML document as a string as parameter and returns a DOM for it. |
| * |
| * On error, returns null and prints a (hopefully) useful message on the monitor. |
| */ |
| @VisibleForTesting(visibility=Visibility.PRIVATE) |
| protected Document getDocument(InputStream xml, ITaskMonitor monitor) { |
| try { |
| DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); |
| factory.setIgnoringComments(true); |
| factory.setNamespaceAware(true); |
| |
| DocumentBuilder builder = factory.newDocumentBuilder(); |
| assert xml.markSupported(); |
| xml.reset(); |
| Document doc = builder.parse(new InputSource(xml)); |
| |
| return doc; |
| } catch (ParserConfigurationException e) { |
| monitor.logError("Failed to create XML document builder"); |
| |
| } catch (SAXException e) { |
| monitor.logError("Failed to parse XML document"); |
| |
| } catch (IOException e) { |
| monitor.logError("Failed to read XML document"); |
| } |
| |
| return null; |
| } |
| |
| /** |
| * Parse all sites defined in the Addons list XML and returns an array of sites. |
| * |
| * @param doc The XML DOM to parse. |
| * @param nsUri The addons-list schema URI of the document. |
| * @param baseUrl The base URL of the caller (e.g. where addons-list-N.xml was fetched from.) |
| * @param monitor A non-null monitor to print to. |
| */ |
| @VisibleForTesting(visibility=Visibility.PRIVATE) |
| protected Site[] parseAddonsList( |
| Document doc, |
| String nsUri, |
| String baseUrl, |
| ITaskMonitor monitor) { |
| |
| String testBaseUrl = System.getenv("SDK_TEST_BASE_URL"); //$NON-NLS-1$ |
| if (testBaseUrl != null) { |
| if (testBaseUrl.length() <= 0 || !testBaseUrl.endsWith("/")) { //$NON-NLS-1$ |
| testBaseUrl = null; |
| } |
| } |
| |
| Node root = getFirstChild(doc, nsUri, SdkAddonsListConstants.NODE_SDK_ADDONS_LIST); |
| if (root != null) { |
| ArrayList<Site> sites = new ArrayList<Site>(); |
| |
| for (Node child = root.getFirstChild(); |
| child != null; |
| child = child.getNextSibling()) { |
| if (child.getNodeType() == Node.ELEMENT_NODE && |
| nsUri.equals(child.getNamespaceURI())) { |
| |
| String elementName = child.getLocalName(); |
| SiteType type = null; |
| |
| if (SdkAddonsListConstants.NODE_SYS_IMG_SITE.equals(elementName)) { |
| type = SiteType.SYS_IMG_SITE; |
| |
| } else if (SdkAddonsListConstants.NODE_ADDON_SITE.equals(elementName)) { |
| type = SiteType.ADDON_SITE; |
| } |
| |
| // Not an addon-site nor a sys-img-site, don't process this. |
| if (type == null) { |
| continue; |
| } |
| |
| Node url = getFirstChild(child, nsUri, SdkAddonsListConstants.NODE_URL); |
| Node name = getFirstChild(child, nsUri, SdkAddonsListConstants.NODE_NAME); |
| |
| if (name != null && url != null) { |
| String strUrl = url.getTextContent().trim(); |
| String strName = name.getTextContent().trim(); |
| |
| if (testBaseUrl != null && |
| strUrl.startsWith(SdkRepoConstants.URL_GOOGLE_SDK_SITE)) { |
| strUrl = testBaseUrl + |
| strUrl.substring(SdkRepoConstants.URL_GOOGLE_SDK_SITE.length()); |
| } else if (!strUrl.startsWith("http://") && //$NON-NLS-1$ |
| !strUrl.startsWith("https://")) { //$NON-NLS-1$ |
| // This looks like a relative URL, add the fetcher's base URL to it. |
| strUrl = baseUrl + strUrl; |
| } |
| |
| if (strUrl.length() > 0 && strName.length() > 0) { |
| sites.add(new Site(strUrl, strName, type)); |
| } |
| } |
| } |
| } |
| |
| return sites.toArray(new Site[sites.size()]); |
| } |
| |
| return null; |
| } |
| |
| /** |
| * Returns the first child element with the given XML local name. |
| * If xmlLocalName is null, returns the very first child element. |
| */ |
| private Node getFirstChild(Node node, String nsUri, String xmlLocalName) { |
| |
| for(Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) { |
| if (child.getNodeType() == Node.ELEMENT_NODE && |
| nsUri.equals(child.getNamespaceURI())) { |
| if (xmlLocalName == null || child.getLocalName().equals(xmlLocalName)) { |
| return child; |
| } |
| } |
| } |
| |
| return null; |
| } |
| |
| |
| } |