// -*- mode: java; c-basic-offset: 2; -*- // Copyright 2009-2011 Google, All Rights reserved // Copyright 2011-2012 MIT, All rights reserved // Released under the Apache License, Version 2.0 // http://www.apache.org/licenses/LICENSE-2.0 package com.google.appinventor.components.runtime; import com.google.appinventor.components.annotations.DesignerComponent; import com.google.appinventor.components.annotations.DesignerProperty; import com.google.appinventor.components.annotations.PropertyCategory; import com.google.appinventor.components.annotations.SimpleEvent; import com.google.appinventor.components.annotations.SimpleFunction; import com.google.appinventor.components.annotations.SimpleObject; import com.google.appinventor.components.annotations.SimpleProperty; import com.google.appinventor.components.annotations.UsesLibraries; import com.google.appinventor.components.annotations.UsesPermissions; import com.google.appinventor.components.common.ComponentCategory; import com.google.appinventor.components.common.HtmlEntities; import com.google.appinventor.components.common.PropertyTypeConstants; import com.google.appinventor.components.common.YaVersion; import com.google.appinventor.components.runtime.collect.Lists; import com.google.appinventor.components.runtime.collect.Maps; import com.google.appinventor.components.runtime.util.AsynchUtil; import com.google.appinventor.components.runtime.util.ErrorMessages; import com.google.appinventor.components.runtime.util.FileUtil; import com.google.appinventor.components.runtime.util.GingerbreadUtil; import com.google.appinventor.components.runtime.util.JsonUtil; import com.google.appinventor.components.runtime.util.MediaUtil; import com.google.appinventor.components.runtime.util.SdkLevel; import com.google.appinventor.components.runtime.util.YailList; import android.app.Activity; import android.text.TextUtils; import android.util.Log; import org.json.JSONException; import org.json.JSONObject; import org.json.XML; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; import java.net.CookieHandler; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.ProtocolException; import java.net.URISyntaxException; import java.net.URL; import java.net.URLEncoder; import java.util.List; import java.util.Map; /** * The Original Web component provided functions for HTTP GET and POST requests. * This new version provides added PUT and DELETE requests. * @author lizlooney@google.com (Liz Looney) * @author josmasflores@gmail.com (Jose Dominguez) */ @DesignerComponent(version = YaVersion.WEB_COMPONENT_VERSION, description = "Non-visible component that provides functions for HTTP GET, POST, PUT, and DELETE requests.", category = ComponentCategory.CONNECTIVITY, nonVisible = true, iconName = "images/web.png") @SimpleObject @UsesPermissions(permissionNames = "android.permission.INTERNET," + "android.permission.WRITE_EXTERNAL_STORAGE," + "android.permission.READ_EXTERNAL_STORAGE") @UsesLibraries(libraries = "json.jar") public class Web extends AndroidNonvisibleComponent implements Component { /** * InvalidRequestHeadersException can be thrown from processRequestHeaders. * It is thrown if the list passed to processRequestHeaders contains an item that is not a list. * It is thrown if the list passed to processRequestHeaders contains an item that is a list whose * size is not 2. */ private static class InvalidRequestHeadersException extends Exception { /* * errorNumber could be: * ErrorMessages.ERROR_WEB_REQUEST_HEADER_NOT_LIST * ErrorMessages.ERROR_WEB_REQUEST_HEADER_NOT_TWO_ELEMENTS */ final int errorNumber; final int index; // the index of the invalid header InvalidRequestHeadersException(int errorNumber, int index) { super(); this.errorNumber = errorNumber; this.index = index; } } /** * BuildRequestDataException can be thrown from buildRequestData. * It is thrown if the list passed to buildRequestData contains an item that is not a list. * It is thrown if the list passed to buildRequestData contains an item that is a list whose size is * not 2. */ // VisibleForTesting static class BuildRequestDataException extends Exception { /* * errorNumber could be: * ErrorMessages.ERROR_WEB_BUILD_REQUEST_DATA_NOT_LIST * ErrorMessages.ERROR_WEB_BUILD_REQUEST_DATA_NOT_TWO_ELEMENTS */ final int errorNumber; final int index; // the index of the invalid header BuildRequestDataException(int errorNumber, int index) { super(); this.errorNumber = errorNumber; this.index = index; } } /** * The CapturedProperties class captures the current property values from a Web component before * an asynchronous request is made. This avoids concurrency problems if the user changes a * property value after initiating an asynchronous request. */ private static class CapturedProperties { final String urlString; final URL url; final boolean allowCookies; final boolean saveResponse; final String responseFileName; final Map<String, List<String>> requestHeaders; final Map<String, List<String>> cookies; CapturedProperties(Web web) throws MalformedURLException, InvalidRequestHeadersException { urlString = web.urlString; url = new URL(urlString); allowCookies = web.allowCookies; saveResponse = web.saveResponse; responseFileName = web.responseFileName; requestHeaders = processRequestHeaders(web.requestHeaders); Map<String, List<String>> cookiesTemp = null; if (allowCookies && web.cookieHandler != null) { try { cookiesTemp = web.cookieHandler.get(url.toURI(), requestHeaders); } catch (URISyntaxException e) { // Can't convert the URL to a URI; no cookies for you. } catch (IOException e) { // Sorry, no cookies for you. } } cookies = cookiesTemp; } } private static final String LOG_TAG = "Web"; private static final Map<String, String> mimeTypeToExtension; static { mimeTypeToExtension = Maps.newHashMap(); mimeTypeToExtension.put("application/pdf", "pdf"); mimeTypeToExtension.put("application/zip", "zip"); mimeTypeToExtension.put("audio/mpeg", "mpeg"); mimeTypeToExtension.put("audio/mp3", "mp3"); mimeTypeToExtension.put("audio/mp4", "mp4"); mimeTypeToExtension.put("image/gif", "gif"); mimeTypeToExtension.put("image/jpeg", "jpg"); mimeTypeToExtension.put("image/png", "png"); mimeTypeToExtension.put("image/tiff", "tiff"); mimeTypeToExtension.put("text/plain", "txt"); mimeTypeToExtension.put("text/html", "html"); mimeTypeToExtension.put("text/xml", "xml"); // TODO(lizlooney) - consider adding more mime types. } private final Activity activity; private final CookieHandler cookieHandler; private String urlString = ""; private boolean allowCookies; private YailList requestHeaders = new YailList(); private boolean saveResponse; private String responseFileName = ""; /** * Creates a new Web component. * * @param container the Form that this component is contained in. */ public Web(ComponentContainer container) { super(container.$form()); activity = container.$context(); cookieHandler = (SdkLevel.getLevel() >= SdkLevel.LEVEL_GINGERBREAD) ? GingerbreadUtil.newCookieManager() : null; } /** * This constructor is for testing purposes only. */ protected Web() { super(null); activity = null; cookieHandler = null; } /** * Returns the URL. */ @SimpleProperty(category = PropertyCategory.BEHAVIOR, description = "The URL for the web request.") public String Url() { return urlString; } /** * Specifies the URL. */ @DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_STRING, defaultValue = "") @SimpleProperty public void Url(String url) { urlString = url; } /** * Returns the request headers. */ @SimpleProperty(category = PropertyCategory.BEHAVIOR, description = "The request headers, as a list of two-element sublists. The first element " + "of each sublist represents the request header field name. The second element of each " + "sublist represents the request header field values, either a single value or a list " + "containing multiple values.") public YailList RequestHeaders() { return requestHeaders; } /** * Sets the request headers. * * @param list a list of two-element sublists, each representing a header name and values */ @SimpleProperty public void RequestHeaders(YailList list) { // Call processRequestHeaders to validate the list parameter before setting the requestHeaders // field. try { processRequestHeaders(list); requestHeaders = list; } catch (InvalidRequestHeadersException e) { form.dispatchErrorOccurredEvent(this, "RequestHeaders", e.errorNumber, e.index); } } /** * Returns whether cookies should be allowed */ @SimpleProperty(category = PropertyCategory.BEHAVIOR, description = "Whether the cookies from a response should be saved and used in subsequent " + "requests. Cookies are only supported on Android version 2.3 or greater.") public boolean AllowCookies() { return allowCookies; } /** * Specifies whether cookies should be allowed */ @DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_BOOLEAN, defaultValue = "false") @SimpleProperty public void AllowCookies(boolean allowCookies) { this.allowCookies = allowCookies; if (allowCookies && cookieHandler == null) { form.dispatchErrorOccurredEvent(this, "AllowCookies", ErrorMessages.ERROR_FUNCTIONALITY_NOT_SUPPORTED_WEB_COOKIES); } } /** * Returns whether the response should be saved in a file. */ @SimpleProperty(category = PropertyCategory.BEHAVIOR, description = "Whether the response should be saved in a file.") public boolean SaveResponse() { return saveResponse; } /** * Specifies whether the response should be saved in a file. */ @DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_BOOLEAN, defaultValue = "false") @SimpleProperty public void SaveResponse(boolean saveResponse) { this.saveResponse = saveResponse; } /** * Returns the name of the file where the response should be saved. * If SaveResponse is true and ResponseFileName is empty, then a new file * name will be generated. */ @SimpleProperty(category = PropertyCategory.BEHAVIOR, description = "The name of the file where the response should be saved. If SaveResponse " + "is true and ResponseFileName is empty, then a new file name will be generated.") public String ResponseFileName() { return responseFileName; } /** * Specifies the name of the file where the response should be saved. * If SaveResponse is true and ResponseFileName is empty, then a new file * name will be generated. */ @DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_STRING, defaultValue = "") @SimpleProperty public void ResponseFileName(String responseFileName) { this.responseFileName = responseFileName; } @SimpleFunction(description = "Clears all cookies for this Web component.") public void ClearCookies() { if (cookieHandler != null) { GingerbreadUtil.clearCookies(cookieHandler); } else { form.dispatchErrorOccurredEvent(this, "ClearCookies", ErrorMessages.ERROR_FUNCTIONALITY_NOT_SUPPORTED_WEB_COOKIES); } } /** * Performs an HTTP GET request using the Url property and retrieves the * response.<br> * If the SaveResponse property is true, the response will be saved in a file * and the GotFile event will be triggered. The ResponseFileName property * can be used to specify the name of the file.<br> * If the SaveResponse property is false, the GotText event will be * triggered. */ @SimpleFunction public void Get() { // Capture property values in local variables before running asynchronously. final CapturedProperties webProps = capturePropertyValues("Get"); if (webProps == null) { // capturePropertyValues has already called form.dispatchErrorOccurredEvent return; } AsynchUtil.runAsynchronously(new Runnable() { @Override public void run() { try { performRequest(webProps, null, null, "GET"); } catch (FileUtil.FileException e) { form.dispatchErrorOccurredEvent(Web.this, "Get", e.getErrorMessageNumber()); } catch (Exception e) { Log.e(LOG_TAG, "ERROR_UNABLE_TO_GET", e); form.dispatchErrorOccurredEvent(Web.this, "Get", ErrorMessages.ERROR_WEB_UNABLE_TO_GET, webProps.urlString); } } }); } /** * Performs an HTTP POST request using the Url property and the specified text. * * @param text the text data for the POST request */ @SimpleFunction(description = "Performs an HTTP POST request using the Url property and " + "the specified text.<br>" + "The characters of the text are encoded using UTF-8 encoding.<br>" + "If the SaveResponse property is true, the response will be saved in a file and the " + "GotFile event will be triggered. The responseFileName property can be used to specify " + "the name of the file.<br>" + "If the SaveResponse property is false, the GotText event will be triggered.") public void PostText(final String text) { requestTextImpl(text, "UTF-8", "PostText", "POST"); } /** * Performs an HTTP POST request using the Url property and the specified text. * * @param text the text data for the POST request * @param encoding the character encoding to use when sending the text. If * encoding is empty or null, UTF-8 encoding will be used. */ @SimpleFunction(description = "Performs an HTTP POST request using the Url property and " + "the specified text.<br>" + "The characters of the text are encoded using the given encoding.<br>" + "If the SaveResponse property is true, the response will be saved in a file and the " + "GotFile event will be triggered. The ResponseFileName property can be used to specify " + "the name of the file.<br>" + "If the SaveResponse property is false, the GotText event will be triggered.") public void PostTextWithEncoding(final String text, final String encoding) { requestTextImpl(text, encoding, "PostTextWithEncoding", "POST"); } /** * Performs an HTTP POST request using the Url property and data from the * specified file, and retrieves the response. * * @param path the path of the file for the POST request */ @SimpleFunction(description = "Performs an HTTP POST request using the Url property and " + "data from the specified file.<br>" + "If the SaveResponse property is true, the response will be saved in a file and the " + "GotFile event will be triggered. The ResponseFileName property can be used to specify " + "the name of the file.<br>" + "If the SaveResponse property is false, the GotText event will be triggered.") public void PostFile(final String path) { // Capture property values before running asynchronously. final CapturedProperties webProps = capturePropertyValues("PostFile"); if (webProps == null) { // capturePropertyValues has already called form.dispatchErrorOccurredEvent return; } AsynchUtil.runAsynchronously(new Runnable() { @Override public void run() { try { performRequest(webProps, null, path, "POST"); } catch (FileUtil.FileException e) { form.dispatchErrorOccurredEvent(Web.this, "PostFile", e.getErrorMessageNumber()); } catch (Exception e) { form.dispatchErrorOccurredEvent(Web.this, "PostFile", ErrorMessages.ERROR_WEB_UNABLE_TO_POST_OR_PUT_FILE, path, webProps.urlString); } } }); } /** * Performs an HTTP PUT request using the Url property and the specified text. * * @param text the text data for the PUT request */ @SimpleFunction(description = "Performs an HTTP PUT request using the Url property and " + "the specified text.<br>" + "The characters of the text are encoded using UTF-8 encoding.<br>" + "If the SaveResponse property is true, the response will be saved in a file and the " + "GotFile event will be triggered. The responseFileName property can be used to specify " + "the name of the file.<br>" + "If the SaveResponse property is false, the GotText event will be triggered.") public void PutText(final String text) { requestTextImpl(text, "UTF-8", "PutText", "PUT"); } /** * Performs an HTTP PUT request using the Url property and the specified text. * * @param text the text data for the PUT request * @param encoding the character encoding to use when sending the text. If * encoding is empty or null, UTF-8 encoding will be used. */ @SimpleFunction(description = "Performs an HTTP PUT request using the Url property and " + "the specified text.<br>" + "The characters of the text are encoded using the given encoding.<br>" + "If the SaveResponse property is true, the response will be saved in a file and the " + "GotFile event will be triggered. The ResponseFileName property can be used to specify " + "the name of the file.<br>" + "If the SaveResponse property is false, the GotText event will be triggered.") public void PutTextWithEncoding(final String text, final String encoding) { requestTextImpl(text, encoding, "PutTextWithEncoding", "PUT"); } /** * Performs an HTTP PUT request using the Url property and data from the * specified file, and retrieves the response. * * @param path the path of the file for the PUT request */ @SimpleFunction(description = "Performs an HTTP PUT request using the Url property and " + "data from the specified file.<br>" + "If the SaveResponse property is true, the response will be saved in a file and the " + "GotFile event will be triggered. The ResponseFileName property can be used to specify " + "the name of the file.<br>" + "If the SaveResponse property is false, the GotText event will be triggered.") public void PutFile(final String path) { // Capture property values before running asynchronously. final CapturedProperties webProps = capturePropertyValues("PutFile"); if (webProps == null) { // capturePropertyValues has already called form.dispatchErrorOccurredEvent return; } AsynchUtil.runAsynchronously(new Runnable() { @Override public void run() { try { performRequest(webProps, null, path, "PUT"); } catch (FileUtil.FileException e) { form.dispatchErrorOccurredEvent(Web.this, "PutFile", e.getErrorMessageNumber()); } catch (Exception e) { form.dispatchErrorOccurredEvent(Web.this, "PutFile", ErrorMessages.ERROR_WEB_UNABLE_TO_POST_OR_PUT_FILE, path, webProps.urlString); } } }); } /** * Performs an HTTP DELETE request using the Url property and retrieves the * response.<br> * If the SaveResponse property is true, the response will be saved in a file * and the GotFile event will be triggered. The ResponseFileName property * can be used to specify the name of the file.<br> * If the SaveResponse property is false, the GotText event will be * triggered. */ @SimpleFunction public void Delete() { // Capture property values in local variables before running asynchronously. final CapturedProperties webProps = capturePropertyValues("Delete"); if (webProps == null) { // capturePropertyValues has already called form.dispatchErrorOccurredEvent return; } AsynchUtil.runAsynchronously(new Runnable() { @Override public void run() { try { performRequest(webProps, null, null, "DELETE"); } catch (FileUtil.FileException e) { form.dispatchErrorOccurredEvent(Web.this, "Delete", e.getErrorMessageNumber()); } catch (Exception e) { form.dispatchErrorOccurredEvent(Web.this, "Delete", ErrorMessages.ERROR_WEB_UNABLE_TO_DELETE, webProps.urlString); } } }); } /* * Performs an HTTP GET, POST, PUT or DELETE request using the Url property and the specified * text, and retrieves the response asynchronously.<br> * The characters of the text are encoded using the given encoding.<br> * If the SaveResponse property is true, the response will be saved in a file * and the GotFile event will be triggered. The ResponseFileName property * can be used to specify the name of the file.<br> * If the SaveResponse property is false, the GotText event will be * triggered. * * @param text the text data for the POST or PUT request * @param encoding the character encoding to use when sending the text. If * encoding is empty or null, UTF-8 encoding will be used. * @param functionName the name of the function, used when dispatching errors * @param httpVerb the HTTP operation to be performed: GET, POST, PUT or DELETE */ private void requestTextImpl(final String text, final String encoding, final String functionName, final String httpVerb) { // Capture property values before running asynchronously. final CapturedProperties webProps = capturePropertyValues(functionName); if (webProps == null) { // capturePropertyValues has already called form.dispatchErrorOccurredEvent return; } AsynchUtil.runAsynchronously(new Runnable() { @Override public void run() { // Convert text to bytes using the encoding. byte[] requestData; try { if (encoding == null || encoding.length() == 0) { requestData = text.getBytes("UTF-8"); } else { requestData = text.getBytes(encoding); } } catch (UnsupportedEncodingException e) { form.dispatchErrorOccurredEvent(Web.this, functionName, ErrorMessages.ERROR_WEB_UNSUPPORTED_ENCODING, encoding); return; } try { performRequest(webProps, requestData, null, httpVerb); } catch (FileUtil.FileException e) { form.dispatchErrorOccurredEvent(Web.this, functionName, e.getErrorMessageNumber()); } catch (Exception e) { form.dispatchErrorOccurredEvent(Web.this, functionName, ErrorMessages.ERROR_WEB_UNABLE_TO_POST_OR_PUT, text, webProps.urlString); } } }); } /** * Event indicating that a request has finished. * * @param url the URL used for the request * @param responseCode the response code from the server * @param responseType the mime type of the response * @param responseContent the response content from the server */ @SimpleEvent public void GotText(String url, int responseCode, String responseType, String responseContent) { // invoke the application's "GotText" event handler. EventDispatcher.dispatchEvent(this, "GotText", url, responseCode, responseType, responseContent); } /** * Event indicating that a request has finished. * * @param url the URL used for the request * @param responseCode the response code from the server * @param responseType the mime type of the response * @param fileName the full path name of the saved file */ @SimpleEvent public void GotFile(String url, int responseCode, String responseType, String fileName) { // invoke the application's "GotFile" event handler. EventDispatcher.dispatchEvent(this, "GotFile", url, responseCode, responseType, fileName); } /** * Converts a list of two-element sublists, representing name and value pairs, to a * string formatted as application/x-www-form-urlencoded media type, suitable to pass to * PostText. * * @param list a list of two-element sublists representing name and value pairs */ @SimpleFunction public String BuildRequestData(YailList list) { try { return buildRequestData(list); } catch (BuildRequestDataException e) { form.dispatchErrorOccurredEvent(this, "BuildRequestData", e.errorNumber, e.index); return ""; } } /* * Converts a list of two-element sublists, representing name and value pairs, to a * string formatted as application/x-www-form-urlencoded media type, suitable to pass to * PostText. * * @param list a list of two-element sublists representing name and value pairs * @throws BuildPostDataException if the list is not valid */ // VisibleForTesting String buildRequestData(YailList list) throws BuildRequestDataException { StringBuilder sb = new StringBuilder(); String delimiter = ""; for (int i = 0; i < list.size(); i++) { Object item = list.getObject(i); // Each item must be a two-element sublist. if (item instanceof YailList) { YailList sublist = (YailList) item; if (sublist.size() == 2) { // The first element is the name. String name = sublist.getObject(0).toString(); // The second element is the value. String value = sublist.getObject(1).toString(); sb.append(delimiter).append(UriEncode(name)).append('=').append(UriEncode(value)); } else { throw new BuildRequestDataException( ErrorMessages.ERROR_WEB_BUILD_REQUEST_DATA_NOT_TWO_ELEMENTS, i + 1); } } else { throw new BuildRequestDataException(ErrorMessages.ERROR_WEB_BUILD_REQUEST_DATA_NOT_LIST, i + 1); } delimiter = "&"; } return sb.toString(); } /** * Encodes the given text value so that it can be used in a URL. * * @param text the text to encode * @return the encoded text */ @SimpleFunction public String UriEncode(String text) { try { return URLEncoder.encode(text, "UTF-8"); } catch (UnsupportedEncodingException e) { // If UTF-8 is not supported, we're in big trouble! // According to Javadoc and Android documentation for java.nio.charset.Charset, UTF-8 is // available on every Java implementation. Log.e(LOG_TAG, "UTF-8 is unsupported?", e); return ""; } } /** * Decodes the given JSON encoded value to produce a corresponding AppInventor value. * A JSON list [x, y, z] decodes to a list (x y z), A JSON object with name A and value B, * (denoted as A:B enclosed in curly braces) decodes to a list * ((A B)), that is, a list containing the two-element list (A B). * * @param jsonText the JSON text to decode * @return the decoded text */ @SimpleFunction // This returns an object, which in general will be a Java ArrayList, String, Boolean, Integer, // or Double. // The object will be sanitized to produce the corresponding Yail data by call-component-method. // That mechanism would need to be extended if we ever change JSON decoding to produce // dictionaries rather than lists // TOOD(hal): Provide an alternative way to decode JSON objects to dictionaries. Maybe with // renaming this JsonTextDecodeWithPairs and making JsonTextDecode the one to use // dictionaries public Object JsonTextDecode(String jsonText) { try { return decodeJsonText(jsonText); } catch (IllegalArgumentException e) { form.dispatchErrorOccurredEvent(this, "JsonTextDecode", ErrorMessages.ERROR_WEB_JSON_TEXT_DECODE_FAILED, jsonText); return ""; } } /** * Decodes the given JSON encoded value. * * @param jsonText the JSON text to decode * @return the decoded object * @throws IllegalArgumentException if the JSON text can't be decoded */ // VisibleForTesting static Object decodeJsonText(String jsonText) throws IllegalArgumentException { try { return JsonUtil.getObjectFromJson(jsonText); } catch (JSONException e) { throw new IllegalArgumentException("jsonText is not a legal JSON value"); } } /** * Decodes the given XML string to produce a list structure. <tag>string</tag> decodes to * a list that contains a pair of tag and string. More generally, if obj1, obj2, ... * are tag-delimited XML strings, then <tag>obj1 obj2 ...</tag> decodes to a list * that contains a pair whose first element is tag and whose second element is the * list of the decoded obj's, ordered alphabetically by tags. Examples: * <foo>123</foo> decodes to a one-item list containing the pair (foo, 123) * <foo>1 2 3</foo> decodes to a one-item list containing the pair (foo,"1 2 3") * <a><foo>1 2 3</foo><bar>456</bar></a> decodes to a list containing the pair * (a,X) where X is a 2-item list that contains the pair (bar,123) and the pair (foo,"1 2 3"). * If the sequence of obj's mixes tag-delimited and non-tag-delimited * items, then the non-tag-delimited items are pulled out of the sequence and wrapped * with a "content" tag. For example, decoding <a><bar>456</bar>many<foo>1 2 3</foo>apples</a> * is similar to above, except that the list X is a 3-item list that contains the additional pair * whose first item is the string "content", and whose second item is the list (many, apples). * This method signals an error and returns the empty list if the result is not well-formed XML. * * @param jsonText the JSON text to decode * @return the decoded text */ // This method works by by first converting the XML to JSON and then decoding the JSON. @SimpleFunction(description = "Decodes the given XML string to produce a list structure. " + "See the App Inventor documentation on \"Other topics, notes, and details\" for information.") // The above description string is punted because I can't figure out how to write the // documentation string in a way that will look work both as a tooltip and in the autogenerated // HTML for the component documentation on the Web. It's too long for a tooltip, anyway. public Object XMLTextDecode(String XmlText) { try { JSONObject json = XML.toJSONObject(XmlText); return JsonTextDecode(json.toString()); } catch (JSONException e) { // We could be more precise and signal different errors for the conversion to JSON // versus the decoding of that JSON, but showing the actual error message should // be good enough. Log.e("Exception in XMLTextDecode", e.getMessage()); form.dispatchErrorOccurredEvent(this, "XMLTextDecode", ErrorMessages.ERROR_WEB_JSON_TEXT_DECODE_FAILED, e.getMessage()); // This XMLTextDecode should always return a list, even in the case of an error return YailList.makeEmptyList(); } } /** * Decodes the given HTML text value. * * <pre> * HTML Character Entities such as &, <, >, ', and " are * changed to &, <, >, ', and ". * Entities such as &#xhhhh, and &#nnnn are changed to the appropriate characters. * </pre> * * @param htmlText the HTML text to decode * @return the decoded text */ @SimpleFunction(description = "Decodes the given HTML text value. HTML character entities " + "such as &amp;, &lt;, &gt;, &apos;, and &quot; are changed to " + "&, <, >, ', and ". Entities such as &#xhhhh, and &#nnnn " + "are changed to the appropriate characters.") public String HtmlTextDecode(String htmlText) { try { return HtmlEntities.decodeHtmlText(htmlText); } catch (IllegalArgumentException e) { form.dispatchErrorOccurredEvent(this, "HtmlTextDecode", ErrorMessages.ERROR_WEB_HTML_TEXT_DECODE_FAILED, htmlText); return ""; } } /* * Perform a HTTP GET or POST request. * This method is always run on a different thread than the event thread. It does not use any * property value fields because the properties may be changed while it is running. Instead, it * uses the parameters. * If either postData or postFile is non-null, then a post request is performed. * If both postData and postFile are non-null, postData takes precedence over postFile. * If postData and postFile are both null, then a get request is performed. * If saveResponse is true, the response will be saved in a file and the GotFile event will be * triggered. responseFileName specifies the name of the file. * If saveResponse is false, the GotText event will be triggered. * * This method can throw an IOException. The caller is responsible for catching it and * triggering the appropriate error event. * * @param webProps the captured property values needed for the request * @param postData the data for the post request if it is not coming from a file, can be null * @param postFile the path of the file containing data for the post request if it is coming from * a file, can be null * * @throws IOException */ private void performRequest(final CapturedProperties webProps, byte[] postData, String postFile, String httpVerb) throws IOException { // Open the connection. HttpURLConnection connection = openConnection(webProps, httpVerb); if (connection != null) { try { if (postData != null) { writeRequestData(connection, postData); } else if (postFile != null) { writeRequestFile(connection, postFile); } // Get the response. final int responseCode = connection.getResponseCode(); final String responseType = getResponseType(connection); processResponseCookies(connection); if (saveResponse) { final String path = saveResponseContent(connection, webProps.responseFileName, responseType); // Dispatch the event. activity.runOnUiThread(new Runnable() { @Override public void run() { GotFile(webProps.urlString, responseCode, responseType, path); } }); } else { final String responseContent = getResponseContent(connection); // Dispatch the event. activity.runOnUiThread(new Runnable() { @Override public void run() { GotText(webProps.urlString, responseCode, responseType, responseContent); } }); } } finally { connection.disconnect(); } } } /** * Open a connection to the resource and set the HTTP action to PUT or DELETE if it is one of * them. GET would be the default, and POST is set in writeRequestData or writeRequestFile * @param webProps the properties of the connection, set as properties in the component * @param httpVerb One of GET/POST/PUT/DELETE * @return a HttpURL Connection * @throws IOException * @throws ClassCastException * @throws ProtocolException thrown if the method in setRequestMethod is not correct */ private static HttpURLConnection openConnection(CapturedProperties webProps, String httpVerb) throws IOException, ClassCastException, ProtocolException { HttpURLConnection connection = (HttpURLConnection) webProps.url.openConnection(); if (httpVerb.equals("PUT") || httpVerb.equals("DELETE")){ // Set the Request Method; GET is the default, and if it is a POST, it will be marked as such // with setDoOutput in writeRequestFile or writeRequestData connection.setRequestMethod(httpVerb); } // Request Headers for (Map.Entry<String, List<String>> header : webProps.requestHeaders.entrySet()) { String name = header.getKey(); for (String value : header.getValue()) { connection.addRequestProperty(name, value); } } // Cookies if (webProps.cookies != null) { for (Map.Entry<String, List<String>> cookie : webProps.cookies.entrySet()) { String name = cookie.getKey(); for (String value : cookie.getValue()) { connection.addRequestProperty(name, value); } } } return connection; } private static void writeRequestData(HttpURLConnection connection, byte[] postData) throws IOException { // According to the documentation at // http://developer.android.com/reference/java/net/HttpURLConnection.html // HttpURLConnection uses the GET method by default. It will use POST if setDoOutput(true) has // been called. connection.setDoOutput(true); // This makes it something other than a HTTP GET. // Write the data. connection.setFixedLengthStreamingMode(postData.length); BufferedOutputStream out = new BufferedOutputStream(connection.getOutputStream()); try { out.write(postData, 0, postData.length); out.flush(); } finally { out.close(); } } private void writeRequestFile(HttpURLConnection connection, String path) throws IOException { // Use MediaUtil.openMedia to open the file. This means that path could be file on the SD card, // an asset, a contact picture, etc. BufferedInputStream in = new BufferedInputStream(MediaUtil.openMedia(form, path)); try { // Write the file's data. // According to the documentation at // http://developer.android.com/reference/java/net/HttpURLConnection.html // HttpURLConnection uses the GET method by default. It will use POST if setDoOutput(true) has // been called. connection.setDoOutput(true); // This makes it something other than a HTTP GET. connection.setChunkedStreamingMode(0); BufferedOutputStream out = new BufferedOutputStream(connection.getOutputStream()); try { while (true) { int b = in.read(); if (b == -1) { break; } out.write(b); } out.flush(); } finally { out.close(); } } finally { in.close(); } } private static String getResponseType(HttpURLConnection connection) { String responseType = connection.getContentType(); return (responseType != null) ? responseType : ""; } private void processResponseCookies(HttpURLConnection connection) { if (allowCookies && cookieHandler != null) { try { Map<String, List<String>> headerFields = connection.getHeaderFields(); cookieHandler.put(connection.getURL().toURI(), headerFields); } catch (URISyntaxException e) { // Can't convert the URL to a URI; no cookies for you. } catch (IOException e) { // Sorry, no cookies for you. } } } private static String getResponseContent(HttpURLConnection connection) throws IOException { // Use the content encoding to convert bytes to characters. String encoding = connection.getContentEncoding(); if (encoding == null) { encoding = "UTF-8"; } InputStreamReader reader = new InputStreamReader(getConnectionStream(connection), encoding); try { int contentLength = connection.getContentLength(); StringBuilder sb = (contentLength != -1) ? new StringBuilder(contentLength) : new StringBuilder(); char[] buf = new char[1024]; int read; while ((read = reader.read(buf)) != -1) { sb.append(buf, 0, read); } return sb.toString(); } finally { reader.close(); } } private static String saveResponseContent(HttpURLConnection connection, String responseFileName, String responseType) throws IOException { File file = createFile(responseFileName, responseType); BufferedInputStream in = new BufferedInputStream(getConnectionStream(connection), 0x1000); try { BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file), 0x1000); try { // Copy the contents from the input stream to the output stream. while (true) { int b = in.read(); if (b == -1) { break; } out.write(b); } out.flush(); } finally { out.close(); } } finally { in.close(); } return file.getAbsolutePath(); } private static InputStream getConnectionStream(HttpURLConnection connection) { // According to the Android reference documentation for HttpURLConnection: If the HTTP response // indicates that an error occurred, getInputStream() will throw an IOException. Use // getErrorStream() to read the error response. try { return connection.getInputStream(); } catch (IOException e1) { // Use the error response. return connection.getErrorStream(); } } private static File createFile(String fileName, String responseType) throws IOException, FileUtil.FileException { // If a fileName was specified, use it. if (!TextUtils.isEmpty(fileName)) { return FileUtil.getExternalFile(fileName); } // Otherwise, try to determine an appropriate file extension from the responseType. // The response type could contain extra information that we don't need. For example, it might // be "text/html; charset=ISO-8859-1". We just want to look at the part before the semicolon. int indexOfSemicolon = responseType.indexOf(';'); if (indexOfSemicolon != -1) { responseType = responseType.substring(0, indexOfSemicolon); } String extension = mimeTypeToExtension.get(responseType); if (extension == null) { extension = "tmp"; } return FileUtil.getDownloadFile(extension); } /* * Converts request headers (a YailList) into the structure that can be used with the Java API * (a Map<String, List<String>>). If the request headers contains an invalid element, an * InvalidRequestHeadersException will be thrown. */ private static Map<String, List<String>> processRequestHeaders(YailList list) throws InvalidRequestHeadersException { Map<String, List<String>> requestHeadersMap = Maps.newHashMap(); for (int i = 0; i < list.size(); i++) { Object item = list.getObject(i); // Each item must be a two-element sublist. if (item instanceof YailList) { YailList sublist = (YailList) item; if (sublist.size() == 2) { // The first element is the request header field name. String fieldName = sublist.getObject(0).toString(); // The second element contains the request header field values. Object fieldValues = sublist.getObject(1); // Build an entry (key and values) for the requestHeadersMap. String key = fieldName; List<String> values = Lists.newArrayList(); // If there is just one field value, it is specified as a single non-list item (for // example, it can be a text value). If there are multiple field values, they are // specified as a list. if (fieldValues instanceof YailList) { // It's a list. There are multiple field values. YailList multipleFieldsValues = (YailList) fieldValues; for (int j = 0; j < multipleFieldsValues.size(); j++) { Object value = multipleFieldsValues.getObject(j); values.add(value.toString()); } } else { // It's a single non-list item. There is just one field value. Object singleFieldValue = fieldValues; values.add(singleFieldValue.toString()); } // Put the entry into the requestHeadersMap. requestHeadersMap.put(key, values); } else { // The sublist doesn't contain two elements. throw new InvalidRequestHeadersException( ErrorMessages.ERROR_WEB_REQUEST_HEADER_NOT_TWO_ELEMENTS, i + 1); } } else { // The item isn't a sublist. throw new InvalidRequestHeadersException( ErrorMessages.ERROR_WEB_REQUEST_HEADER_NOT_LIST, i + 1); } } return requestHeadersMap; } /* * Captures the current property values that are needed for an HTTP request. If an error occurs * while validating the Url or RequestHeaders property values, this method calls * form.dispatchErrorOccurredEvent and returns null. * * @param functionName the name of the function, used when dispatching errors */ private CapturedProperties capturePropertyValues(String functionName) { try { return new CapturedProperties(this); } catch (MalformedURLException e) { form.dispatchErrorOccurredEvent(this, functionName, ErrorMessages.ERROR_WEB_MALFORMED_URL, urlString); } catch (InvalidRequestHeadersException e) { form.dispatchErrorOccurredEvent(this, functionName, e.errorNumber, e.index); } return null; } }