From 05bc1865a63a09a2fde92adeb1ffc59884ef4dc3 Mon Sep 17 00:00:00 2001 From: Andrew Grieve Date: Tue, 25 Sep 2012 13:25:06 -0400 Subject: [PATCH] Change FileTransfer to use the new plugin signature. Fixes slow abort(): https://issues.apache.org/jira/browse/CB-1516 Fixes abort() race condition: https://issues.apache.org/jira/browse/CB-1532 --- .../src/org/apache/cordova/FileTransfer.java | 917 ++++++++++-------- 1 file changed, 497 insertions(+), 420 deletions(-) diff --git a/framework/src/org/apache/cordova/FileTransfer.java b/framework/src/org/apache/cordova/FileTransfer.java index deee2915..be3a11f5 100644 --- a/framework/src/org/apache/cordova/FileTransfer.java +++ b/framework/src/org/apache/cordova/FileTransfer.java @@ -18,6 +18,7 @@ */ package org.apache.cordova; +import java.io.Closeable; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; @@ -27,13 +28,14 @@ import java.io.FileOutputStream; import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; import java.net.URLDecoder; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.util.HashSet; +import java.util.HashMap; import java.util.Iterator; import javax.net.ssl.HostnameVerifier; @@ -68,15 +70,30 @@ public class FileTransfer extends CordovaPlugin { public static int CONNECTION_ERR = 3; public static int ABORTED_ERR = 4; - private static HashSet abortTriggered = new HashSet(); + private static HashMap activeRequests = new HashMap(); + private static final int MAX_BUFFER_SIZE = 16 * 1024; - private SSLSocketFactory defaultSSLSocketFactory = null; - private HostnameVerifier defaultHostnameVerifier = null; - private static final class AbortException extends Exception { - private static final long serialVersionUID = 1L; - public AbortException(String str) { - super(str); + private static SSLSocketFactory defaultSSLSocketFactory = null; + + private static final class RequestContext { + String source; + String target; + CallbackContext callbackContext; + InputStream currentInputStream; + OutputStream currentOutputStream; + boolean aborted; + RequestContext(String source, String target, CallbackContext callbackContext) { + this.source = source; + this.target = target; + this.callbackContext = callbackContext; + } + void sendPluginResult(PluginResult pluginResult) { + synchronized (this) { + if (!aborted) { + callbackContext.sendPluginResult(pluginResult); + } + } } } @@ -113,21 +130,11 @@ public class FileTransfer extends CordovaPlugin { } } - /* (non-Javadoc) - * @see org.apache.cordova.api.Plugin#execute(java.lang.String, org.json.JSONArray, java.lang.String) - */ @Override public boolean execute(String action, JSONArray args, final CallbackContext callbackContext) throws JSONException { if (action.equals("upload") || action.equals("download")) { - String source = null; - String target = null; - try { - source = args.getString(0); - target = args.getString(1); - } catch (JSONException e) { - Log.d(LOG_TAG, "Missing source or target"); - return new PluginResult(PluginResult.Status.JSON_EXCEPTION, "Missing source or target"); - } + String source = args.getString(0); + String target = args.getString(1); if (action.equals("upload")) { upload(URLDecoder.decode(source), target, args, callbackContext); @@ -157,273 +164,309 @@ public class FileTransfer extends CordovaPlugin { * args[5] params key:value pairs of user-defined parameters * @return FileUploadResult containing result of upload request */ - private PluginResult upload(String source, String target, JSONArray args, CallbackContext callbackContext) { + private void upload(final String source, final String target, JSONArray args, CallbackContext callbackContext) throws JSONException { Log.d(LOG_TAG, "upload " + source + " to " + target); - HttpURLConnection conn = null; + // Setup the options + final String fileKey = getArgument(args, 2, "file"); + final String fileName = getArgument(args, 3, "image.jpg"); + final String mimeType = getArgument(args, 4, "image/jpeg"); + final JSONObject params = args.optJSONObject(5) == null ? new JSONObject() : args.optJSONObject(5); + final boolean trustEveryone = args.optBoolean(6); + // Always use chunked mode unless set to false as per API + final boolean chunkedMode = args.optBoolean(7) || args.isNull(7); + // Look for headers on the params map for backwards compatibility with older Cordova versions. + final JSONObject headers = args.optJSONObject(8) == null ? params.optJSONObject("headers") : args.optJSONObject(8); + final String objectId = args.getString(9); + + Log.d(LOG_TAG, "fileKey: " + fileKey); + Log.d(LOG_TAG, "fileName: " + fileName); + Log.d(LOG_TAG, "mimeType: " + mimeType); + Log.d(LOG_TAG, "params: " + params); + Log.d(LOG_TAG, "trustEveryone: " + trustEveryone); + Log.d(LOG_TAG, "chunkedMode: " + chunkedMode); + Log.d(LOG_TAG, "headers: " + headers); + Log.d(LOG_TAG, "objectId: " + objectId); + + final URL url; try { - // Setup the options - String fileKey = getArgument(args, 2, "file"); - String fileName = getArgument(args, 3, "image.jpg"); - String mimeType = getArgument(args, 4, "image/jpeg"); - JSONObject params = args.optJSONObject(5); - if (params == null) params = new JSONObject(); - boolean trustEveryone = args.optBoolean(6); - boolean chunkedMode = args.optBoolean(7) || args.isNull(7); //Always use chunked mode unless set to false as per API - JSONObject headers = args.optJSONObject(8); - // Look for headers on the params map for backwards compatibility with older Cordova versions. - if (headers == null && params != null) { - headers = params.optJSONObject("headers"); - } - String objectId = args.getString(9); - - Log.d(LOG_TAG, "fileKey: " + fileKey); - Log.d(LOG_TAG, "fileName: " + fileName); - Log.d(LOG_TAG, "mimeType: " + mimeType); - Log.d(LOG_TAG, "params: " + params); - Log.d(LOG_TAG, "trustEveryone: " + trustEveryone); - Log.d(LOG_TAG, "chunkedMode: " + chunkedMode); - Log.d(LOG_TAG, "headers: " + headers); - Log.d(LOG_TAG, "objectId: " + objectId); - - // Create return object - FileUploadResult result = new FileUploadResult(); - FileProgressResult progress = new FileProgressResult(); - - // Get a input stream of the file on the phone - InputStream inputStream = getPathFromUri(source); - - DataOutputStream dos = null; - - int bytesRead, bytesAvailable, bufferSize; - long totalBytes; - byte[] buffer; - int maxBufferSize = 8096; - - //------------------ CLIENT REQUEST - // open a URL connection to the server - URL url = new URL(target); - boolean useHttps = url.getProtocol().toLowerCase().equals("https"); - // Open a HTTP connection to the URL based on protocol - if (useHttps) { - // Using standard HTTPS connection. Will not allow self signed certificate - if (!trustEveryone) { - conn = (HttpsURLConnection) url.openConnection(); - } - // Use our HTTPS connection that blindly trusts everyone. - // This should only be used in debug environments - else { - // Setup the HTTPS connection class to trust everyone - trustAllHosts(); - HttpsURLConnection https = (HttpsURLConnection) url.openConnection(); - // Save the current hostnameVerifier - defaultHostnameVerifier = https.getHostnameVerifier(); - // Setup the connection not to verify hostnames - https.setHostnameVerifier(DO_NOT_VERIFY); - conn = https; - } - } - // Return a standard HTTP connection - else { - conn = (HttpURLConnection) url.openConnection(); - } - - // Allow Inputs - conn.setDoInput(true); - - // Allow Outputs - conn.setDoOutput(true); - - // Don't use a cached copy. - conn.setUseCaches(false); - - // Use a post method. - conn.setRequestMethod("POST"); - conn.setRequestProperty("Connection", "Keep-Alive"); - conn.setRequestProperty("Content-Type", "multipart/form-data;boundary=" + BOUNDARY); - - // Set the cookies on the response - String cookie = CookieManager.getInstance().getCookie(target); - if (cookie != null) { - conn.setRequestProperty("Cookie", cookie); - } - - // Handle the other headers - if (headers != null) { - try { - for (Iterator iter = headers.keys(); iter.hasNext(); ) { - String headerKey = iter.next().toString(); - JSONArray headerValues = headers.optJSONArray(headerKey); - if (headerValues == null) { - headerValues = new JSONArray(); - headerValues.put(headers.getString(headerKey)); - } - conn.setRequestProperty(headerKey, headerValues.getString(0)); - for (int i = 1; i < headerValues.length(); ++i) { - conn.addRequestProperty(headerKey, headerValues.getString(i)); - } - } - } catch (JSONException e1) { - // No headers to be manipulated! - } - } - - /* - * Store the non-file portions of the multipart data as a string, so that we can add it - * to the contentSize, since it is part of the body of the HTTP request. - */ - String extraParams = ""; - try { - for (Iterator iter = params.keys(); iter.hasNext();) { - Object key = iter.next(); - if(!String.valueOf(key).equals("headers")) - { - extraParams += LINE_START + BOUNDARY + LINE_END; - extraParams += "Content-Disposition: form-data; name=\"" + key.toString() + "\";"; - extraParams += LINE_END + LINE_END; - extraParams += params.getString(key.toString()); - extraParams += LINE_END; - } - } - } catch (JSONException e) { - Log.e(LOG_TAG, e.getMessage(), e); - } - - extraParams += LINE_START + BOUNDARY + LINE_END; - extraParams += "Content-Disposition: form-data; name=\"" + fileKey + "\";" + " filename=\""; - byte[] extraBytes = extraParams.getBytes("UTF-8"); - - String midParams = "\"" + LINE_END + "Content-Type: " + mimeType + LINE_END + LINE_END; - String tailParams = LINE_END + LINE_START + BOUNDARY + LINE_START + LINE_END; - byte[] fileNameBytes = fileName.getBytes("UTF-8"); - - int stringLength = extraBytes.length + midParams.length() + tailParams.length() + fileNameBytes.length; - Log.d(LOG_TAG, "String Length: " + stringLength); - int fixedLength = -1; - if (inputStream instanceof FileInputStream) { - fixedLength = (int) ((FileInputStream)inputStream).getChannel().size() + stringLength; - progress.setLengthComputable(true); - progress.setTotal(fixedLength); - } - Log.d(LOG_TAG, "Content Length: " + fixedLength); - // setFixedLengthStreamingMode causes and OutOfMemoryException on pre-Froyo devices. - // http://code.google.com/p/android/issues/detail?id=3164 - // It also causes OOM if HTTPS is used, even on newer devices. - chunkedMode = chunkedMode && (Build.VERSION.SDK_INT < Build.VERSION_CODES.FROYO || useHttps); - chunkedMode = chunkedMode || (fixedLength == -1); - - if (chunkedMode) { - conn.setChunkedStreamingMode(maxBufferSize); - // Although setChunkedStreamingMode sets this header, setting it explicitly here works - // around an OutOfMemoryException when using https. - conn.setRequestProperty("Transfer-Encoding", "chunked"); - } else { - conn.setFixedLengthStreamingMode(fixedLength); - } - - dos = new DataOutputStream( conn.getOutputStream() ); - //We don't want to change encoding, we just want this to write for all Unicode. - dos.write(extraBytes); - dos.write(fileNameBytes); - dos.writeBytes(midParams); - - // create a buffer of maximum size - bytesAvailable = inputStream.available(); - bufferSize = Math.min(bytesAvailable, maxBufferSize); - buffer = new byte[bufferSize]; - - // read file and write it into form... - bytesRead = inputStream.read(buffer, 0, bufferSize); - totalBytes = 0; - - long prevBytesRead = 0; - while (bytesRead > 0) { - totalBytes += bytesRead; - result.setBytesSent(totalBytes); - dos.write(buffer, 0, bufferSize); - if (totalBytes > prevBytesRead + 102400) { - prevBytesRead = totalBytes; - Log.d(LOG_TAG, "Uploaded " + totalBytes + " of " + fixedLength + " bytes"); - } - bytesAvailable = inputStream.available(); - bufferSize = Math.min(bytesAvailable, maxBufferSize); - bytesRead = inputStream.read(buffer, 0, bufferSize); - if (objectId != null) { - // Only send progress callbacks if the JS code sent us an object ID, - // so we don't spam old versions with unrecognized callbacks. - progress.setLoaded(totalBytes); - PluginResult progressResult = new PluginResult(PluginResult.Status.OK, progress.toJSONObject()); - progressResult.setKeepCallback(true); - callbackContext.sendPluginResult(progressResult); - } - synchronized (abortTriggered) { - if (objectId != null && abortTriggered.contains(objectId)) { - abortTriggered.remove(objectId); - throw new AbortException("upload aborted"); - } - } - } - - // send multipart form data necessary after file data... - dos.writeBytes(tailParams); - - // close streams - inputStream.close(); - dos.flush(); - dos.close(); - - //------------------ read the SERVER RESPONSE - StringBuffer responseString = new StringBuffer(""); - DataInputStream inStream = new DataInputStream(getInputStream(conn)); - - String line; - while (( line = inStream.readLine()) != null) { - responseString.append(line); - } - Log.d(LOG_TAG, "got response from server"); - Log.d(LOG_TAG, responseString.toString()); - - // send request and retrieve response - result.setResponseCode(conn.getResponseCode()); - result.setResponse(responseString.toString()); - - inStream.close(); - - // Revert back to the proper verifier and socket factories - if (trustEveryone && url.getProtocol().toLowerCase().equals("https")) { - ((HttpsURLConnection) conn).setHostnameVerifier(defaultHostnameVerifier); - HttpsURLConnection.setDefaultSSLSocketFactory(defaultSSLSocketFactory); - } - - Log.d(LOG_TAG, "****** About to return a result from upload"); - return new PluginResult(PluginResult.Status.OK, result.toJSONObject()); - - } catch (FileNotFoundException e) { - JSONObject error = createFileTransferError(FILE_NOT_FOUND_ERR, source, target, conn); - Log.e(LOG_TAG, error.toString(), e); - return new PluginResult(PluginResult.Status.IO_EXCEPTION, error); + url = new URL(target); } catch (MalformedURLException e) { - JSONObject error = createFileTransferError(INVALID_URL_ERR, source, target, conn); + JSONObject error = createFileTransferError(INVALID_URL_ERR, source, target, 0); Log.e(LOG_TAG, error.toString(), e); - return new PluginResult(PluginResult.Status.IO_EXCEPTION, error); - } catch (IOException e) { - JSONObject error = createFileTransferError(CONNECTION_ERR, source, target, conn); - Log.e(LOG_TAG, error.toString(), e); - return new PluginResult(PluginResult.Status.IO_EXCEPTION, error); - } catch (JSONException e) { - Log.e(LOG_TAG, e.getMessage(), e); - return new PluginResult(PluginResult.Status.JSON_EXCEPTION); - } catch (AbortException e) { - JSONObject error = createFileTransferError(ABORTED_ERR, source, target, conn); - return new PluginResult(PluginResult.Status.ERROR, error); - } catch (Throwable t) { - // Shouldn't happen, but will - JSONObject error = createFileTransferError(CONNECTION_ERR, source, target, conn); - Log.e(LOG_TAG, error.toString(), t); - return new PluginResult(PluginResult.Status.IO_EXCEPTION, error); - } finally { - if (conn != null) { - conn.disconnect(); + callbackContext.sendPluginResult(new PluginResult(PluginResult.Status.IO_EXCEPTION, error)); + return; + } + final boolean useHttps = url.getProtocol().toLowerCase().equals("https"); + + final RequestContext context = new RequestContext(source, target, callbackContext); + synchronized (activeRequests) { + activeRequests.put(objectId, context); + } + + cordova.getThreadPool().execute(new Runnable() { + @Override + public void run() { + if (context.aborted) { + return; + } + HttpURLConnection conn = null; + HostnameVerifier defaultHostnameVerifier = null; + + try { + // Create return object + FileUploadResult result = new FileUploadResult(); + FileProgressResult progress = new FileProgressResult(); + + //------------------ CLIENT REQUEST + // Open a HTTP connection to the URL based on protocol + if (useHttps) { + // Using standard HTTPS connection. Will not allow self signed certificate + if (!trustEveryone) { + conn = (HttpsURLConnection) url.openConnection(); + } + // Use our HTTPS connection that blindly trusts everyone. + // This should only be used in debug environments + else { + // Setup the HTTPS connection class to trust everyone + trustAllHosts(); + HttpsURLConnection https = (HttpsURLConnection) url.openConnection(); + // Save the current hostnameVerifier + defaultHostnameVerifier = https.getHostnameVerifier(); + // Setup the connection not to verify hostnames + https.setHostnameVerifier(DO_NOT_VERIFY); + conn = https; + } + } + // Return a standard HTTP connection + else { + conn = (HttpURLConnection) url.openConnection(); + } + + // Allow Inputs + conn.setDoInput(true); + + // Allow Outputs + conn.setDoOutput(true); + + // Don't use a cached copy. + conn.setUseCaches(false); + + // Use a post method. + conn.setRequestMethod("POST"); + conn.setRequestProperty("Connection", "Keep-Alive"); + conn.setRequestProperty("Content-Type", "multipart/form-data;boundary=" + BOUNDARY); + + // Set the cookies on the response + String cookie = CookieManager.getInstance().getCookie(target); + if (cookie != null) { + conn.setRequestProperty("Cookie", cookie); + } + + // Handle the other headers + if (headers != null) { + try { + for (Iterator iter = headers.keys(); iter.hasNext(); ) { + String headerKey = iter.next().toString(); + JSONArray headerValues = headers.optJSONArray(headerKey); + if (headerValues == null) { + headerValues = new JSONArray(); + headerValues.put(headers.getString(headerKey)); + } + conn.setRequestProperty(headerKey, headerValues.getString(0)); + for (int i = 1; i < headerValues.length(); ++i) { + conn.addRequestProperty(headerKey, headerValues.getString(i)); + } + } + } catch (JSONException e1) { + // No headers to be manipulated! + } + } + + /* + * Store the non-file portions of the multipart data as a string, so that we can add it + * to the contentSize, since it is part of the body of the HTTP request. + */ + String extraParams = ""; + try { + for (Iterator iter = params.keys(); iter.hasNext();) { + Object key = iter.next(); + if(!String.valueOf(key).equals("headers")) + { + extraParams += LINE_START + BOUNDARY + LINE_END; + extraParams += "Content-Disposition: form-data; name=\"" + key.toString() + "\";"; + extraParams += LINE_END + LINE_END; + extraParams += params.getString(key.toString()); + extraParams += LINE_END; + } + } + } catch (JSONException e) { + Log.e(LOG_TAG, e.getMessage(), e); + } + + extraParams += LINE_START + BOUNDARY + LINE_END; + extraParams += "Content-Disposition: form-data; name=\"" + fileKey + "\";" + " filename=\""; + byte[] extraBytes = extraParams.getBytes("UTF-8"); + + String midParams = "\"" + LINE_END + "Content-Type: " + mimeType + LINE_END + LINE_END; + String tailParams = LINE_END + LINE_START + BOUNDARY + LINE_START + LINE_END; + byte[] fileNameBytes = fileName.getBytes("UTF-8"); + + + // Get a input stream of the file on the phone + InputStream sourceInputStream = getPathFromUri(source); + + int stringLength = extraBytes.length + midParams.length() + tailParams.length() + fileNameBytes.length; + Log.d(LOG_TAG, "String Length: " + stringLength); + int fixedLength = -1; + if (sourceInputStream instanceof FileInputStream) { + fixedLength = (int) ((FileInputStream)sourceInputStream).getChannel().size() + stringLength; + progress.setLengthComputable(true); + progress.setTotal(fixedLength); + } + Log.d(LOG_TAG, "Content Length: " + fixedLength); + // setFixedLengthStreamingMode causes and OutOfMemoryException on pre-Froyo devices. + // http://code.google.com/p/android/issues/detail?id=3164 + // It also causes OOM if HTTPS is used, even on newer devices. + boolean useChunkedMode = chunkedMode && (Build.VERSION.SDK_INT < Build.VERSION_CODES.FROYO || useHttps); + useChunkedMode = useChunkedMode || (fixedLength == -1); + + if (useChunkedMode) { + conn.setChunkedStreamingMode(MAX_BUFFER_SIZE); + // Although setChunkedStreamingMode sets this header, setting it explicitly here works + // around an OutOfMemoryException when using https. + conn.setRequestProperty("Transfer-Encoding", "chunked"); + } else { + conn.setFixedLengthStreamingMode(fixedLength); + } + + DataOutputStream dos = null; + try { + synchronized (context) { + if (context.aborted) { + throw new IOException("Request aborted"); + } + dos = new DataOutputStream( conn.getOutputStream() ); + context.currentOutputStream = dos; + } + //We don't want to change encoding, we just want this to write for all Unicode. + dos.write(extraBytes); + dos.write(fileNameBytes); + dos.writeBytes(midParams); + + // create a buffer of maximum size + int bytesAvailable = sourceInputStream.available(); + int bufferSize = Math.min(bytesAvailable, MAX_BUFFER_SIZE); + byte[] buffer = new byte[bufferSize]; + + // read file and write it into form... + int bytesRead = sourceInputStream.read(buffer, 0, bufferSize); + long totalBytes = 0; + + long prevBytesRead = 0; + while (bytesRead > 0) { + totalBytes += bytesRead; + result.setBytesSent(totalBytes); + dos.write(buffer, 0, bufferSize); + if (totalBytes > prevBytesRead + 102400) { + prevBytesRead = totalBytes; + Log.d(LOG_TAG, "Uploaded " + totalBytes + " of " + fixedLength + " bytes"); + } + bytesAvailable = sourceInputStream.available(); + bufferSize = Math.min(bytesAvailable, MAX_BUFFER_SIZE); + bytesRead = sourceInputStream.read(buffer, 0, bufferSize); + + // Send a progress event. + progress.setLoaded(totalBytes); + PluginResult progressResult = new PluginResult(PluginResult.Status.OK, progress.toJSONObject()); + progressResult.setKeepCallback(true); + context.sendPluginResult(progressResult); + } + + // send multipart form data necessary after file data... + dos.writeBytes(tailParams); + dos.flush(); + } finally { + safeClose(sourceInputStream); + safeClose(dos); + } + context.currentOutputStream = null; + + //------------------ read the SERVER RESPONSE + StringBuffer responseString = new StringBuffer(""); + + DataInputStream inStream = null; + try { + synchronized (context) { + if (context.aborted) { + throw new IOException("Request aborted"); + } + inStream = new DataInputStream(getInputStream(conn)); + context.currentInputStream = inStream; + } + + + String line; + while (( line = inStream.readLine()) != null) { + responseString.append(line); + } + } finally { + safeClose(inStream); + } + + Log.d(LOG_TAG, "got response from server"); + Log.d(LOG_TAG, responseString.toString()); + + // send request and retrieve response + result.setResponseCode(conn.getResponseCode()); + result.setResponse(responseString.toString()); + context.currentInputStream = null; + synchronized (activeRequests) { + activeRequests.remove(objectId); + } + + context.sendPluginResult(new PluginResult(PluginResult.Status.OK, result.toJSONObject())); + } catch (FileNotFoundException e) { + JSONObject error = createFileTransferError(FILE_NOT_FOUND_ERR, source, target, conn); + Log.e(LOG_TAG, error.toString(), e); + context.sendPluginResult(new PluginResult(PluginResult.Status.IO_EXCEPTION, error)); + } catch (IOException e) { + JSONObject error = createFileTransferError(CONNECTION_ERR, source, target, conn); + Log.e(LOG_TAG, error.toString(), e); + context.sendPluginResult(new PluginResult(PluginResult.Status.IO_EXCEPTION, error)); + } catch (JSONException e) { + Log.e(LOG_TAG, e.getMessage(), e); + context.sendPluginResult(new PluginResult(PluginResult.Status.JSON_EXCEPTION)); + } catch (Throwable t) { + // Shouldn't happen, but will + JSONObject error = createFileTransferError(CONNECTION_ERR, source, target, conn); + Log.e(LOG_TAG, error.toString(), t); + context.sendPluginResult(new PluginResult(PluginResult.Status.IO_EXCEPTION, error)); + } finally { + synchronized (activeRequests) { + activeRequests.remove(objectId); + } + + if (conn != null) { + // Revert back to the proper verifier and socket factories + if (trustEveryone && useHttps) { + ((HttpsURLConnection) conn).setHostnameVerifier(defaultHostnameVerifier); + HttpsURLConnection.setDefaultSSLSocketFactory(defaultSSLSocketFactory); + } + + conn.disconnect(); + } + } + } + }); + } + + private static void safeClose(Closeable stream) { + if (stream != null) { + try { + stream.close(); + } catch (IOException e) { + e.printStackTrace(); } } } @@ -469,7 +512,9 @@ public class FileTransfer extends CordovaPlugin { // Install the all-trusting trust manager try { // Backup the current SSL socket factory - defaultSSLSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory(); + if (defaultSSLSocketFactory == null) { + defaultSSLSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory(); + } // Install our all trusting manager SSLContext sc = SSLContext.getInstance("TLS"); sc.init(null, trustAllCerts, new java.security.SecureRandom()); @@ -538,143 +583,166 @@ public class FileTransfer extends CordovaPlugin { * * @param source URL of the server to receive the file * @param target Full path of the file on the file system - * @return JSONObject the downloaded file */ - private PluginResult download(String source, String target, JSONArray args, CallbackContext callbackContext) { + private void download(final String source, final String target, JSONArray args, CallbackContext callbackContext) throws JSONException { Log.d(LOG_TAG, "download " + source + " to " + target); - HttpURLConnection connection = null; + final boolean trustEveryone = args.optBoolean(2); + final String objectId = args.getString(3); + + final URL url; try { - boolean trustEveryone = args.optBoolean(2); - String objectId = args.getString(3); - File file = getFileFromPath(target); + url = new URL(source); + } catch (MalformedURLException e) { + JSONObject error = createFileTransferError(INVALID_URL_ERR, source, target, 0); + Log.e(LOG_TAG, error.toString(), e); + callbackContext.sendPluginResult(new PluginResult(PluginResult.Status.IO_EXCEPTION, error)); + return; + } + final boolean useHttps = url.getProtocol().toLowerCase().equals("https"); + + if (!webView.isUrlWhiteListed(source)) { + Log.w(LOG_TAG, "Source URL is not in white list: '" + source + "'"); + JSONObject error = createFileTransferError(CONNECTION_ERR, source, target, 401); + callbackContext.sendPluginResult(new PluginResult(PluginResult.Status.IO_EXCEPTION, error)); + return; + } - // create needed directories - file.getParentFile().mkdirs(); - - // connect to server - if (webView.isUrlWhiteListed(source)) - { - URL url = new URL(source); - boolean useHttps = url.getProtocol().toLowerCase().equals("https"); - // Open a HTTP connection to the URL based on protocol - if (useHttps) { - // Using standard HTTPS connection. Will not allow self signed certificate - if (!trustEveryone) { - connection = (HttpsURLConnection) url.openConnection(); - } - // Use our HTTPS connection that blindly trusts everyone. - // This should only be used in debug environments - else { - // Setup the HTTPS connection class to trust everyone - trustAllHosts(); - HttpsURLConnection https = (HttpsURLConnection) url.openConnection(); - // Save the current hostnameVerifier - defaultHostnameVerifier = https.getHostnameVerifier(); - // Setup the connection not to verify hostnames - https.setHostnameVerifier(DO_NOT_VERIFY); - connection = https; - } - } - // Return a standard HTTP connection - else { - connection = (HttpURLConnection) url.openConnection(); - } - connection.setRequestMethod("GET"); - - //Add cookie support - String cookie = CookieManager.getInstance().getCookie(source); - if(cookie != null) - { - connection.setRequestProperty("cookie", cookie); - } - - connection.connect(); - - Log.d(LOG_TAG, "Download file: " + url); - - connection.connect(); - - Log.d(LOG_TAG, "Download file:" + url); - InputStream inputStream = getInputStream(connection); - - byte[] buffer = new byte[1024]; - int bytesRead = 0; - long totalBytes = 0; - FileProgressResult progress = new FileProgressResult(); - - if (connection.getContentEncoding() == null) { - // Only trust content-length header if no gzip etc - progress.setLengthComputable(true); - progress.setTotal(connection.getContentLength()); + + final RequestContext context = new RequestContext(source, target, callbackContext); + synchronized (activeRequests) { + activeRequests.put(objectId, context); + } + + cordova.getThreadPool().execute(new Runnable() { + @Override + public void run() { + if (context.aborted) { + return; } + HttpURLConnection connection = null; + HostnameVerifier defaultHostnameVerifier = null; - FileOutputStream outputStream = new FileOutputStream(file); + try { - // write bytes to file - while ((bytesRead = inputStream.read(buffer)) > 0) { - outputStream.write(buffer, 0, bytesRead); - totalBytes += bytesRead; - if (objectId != null) { - // Only send progress callbacks if the JS code sent us an object ID, - // so we don't spam old versions with unrecognized callbacks. - progress.setLoaded(totalBytes); - PluginResult progressResult = new PluginResult(PluginResult.Status.OK, progress.toJSONObject()); - progressResult.setKeepCallback(true); - callbackContext.sendPluginResult(progressResult); - } - synchronized (abortTriggered) { - if (objectId != null && abortTriggered.contains(objectId)) { - abortTriggered.remove(objectId); - throw new AbortException("download aborted"); + // create needed directories + File file = getFileFromPath(target); + file.getParentFile().mkdirs(); + + // connect to server + // Open a HTTP connection to the URL based on protocol + if (useHttps) { + // Using standard HTTPS connection. Will not allow self signed certificate + if (!trustEveryone) { + connection = (HttpsURLConnection) url.openConnection(); + } + // Use our HTTPS connection that blindly trusts everyone. + // This should only be used in debug environments + else { + // Setup the HTTPS connection class to trust everyone + trustAllHosts(); + HttpsURLConnection https = (HttpsURLConnection) url.openConnection(); + // Save the current hostnameVerifier + defaultHostnameVerifier = https.getHostnameVerifier(); + // Setup the connection not to verify hostnames + https.setHostnameVerifier(DO_NOT_VERIFY); + connection = https; } } + // Return a standard HTTP connection + else { + connection = (HttpURLConnection) url.openConnection(); + } + + connection.setRequestMethod("GET"); + + //Add cookie support + String cookie = CookieManager.getInstance().getCookie(source); + if(cookie != null) + { + connection.setRequestProperty("cookie", cookie); + } + + connection.connect(); + + Log.d(LOG_TAG, "Download file:" + url); + + FileProgressResult progress = new FileProgressResult(); + if (connection.getContentEncoding() == null) { + // Only trust content-length header if no gzip etc + progress.setLengthComputable(true); + progress.setTotal(connection.getContentLength()); + } + + FileOutputStream outputStream = new FileOutputStream(file); + InputStream inputStream = null; + + try { + synchronized (context) { + if (context.aborted) { + throw new IOException("Request aborted"); + } + inputStream = getInputStream(connection); + context.currentInputStream = inputStream; + } + + // write bytes to file + byte[] buffer = new byte[MAX_BUFFER_SIZE]; + int bytesRead = 0; + long totalBytes = 0; + while ((bytesRead = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, bytesRead); + totalBytes += bytesRead; + // Send a progress event. + progress.setLoaded(totalBytes); + PluginResult progressResult = new PluginResult(PluginResult.Status.OK, progress.toJSONObject()); + progressResult.setKeepCallback(true); + context.sendPluginResult(progressResult); + } + } finally { + safeClose(inputStream); + safeClose(outputStream); + } + + Log.d(LOG_TAG, "Saved file: " + target); + + // create FileEntry object + FileUtils fileUtil = new FileUtils(); + JSONObject fileEntry = fileUtil.getEntry(file); + + context.sendPluginResult(new PluginResult(PluginResult.Status.OK, fileEntry)); + } catch (FileNotFoundException e) { + JSONObject error = createFileTransferError(FILE_NOT_FOUND_ERR, source, target, connection); + Log.e(LOG_TAG, error.toString(), e); + context.sendPluginResult(new PluginResult(PluginResult.Status.IO_EXCEPTION, error)); + } catch (IOException e) { + JSONObject error = createFileTransferError(CONNECTION_ERR, source, target, connection); + Log.e(LOG_TAG, error.toString(), e); + context.sendPluginResult(new PluginResult(PluginResult.Status.IO_EXCEPTION, error)); + } catch (JSONException e) { + Log.e(LOG_TAG, e.getMessage(), e); + context.sendPluginResult(new PluginResult(PluginResult.Status.JSON_EXCEPTION)); + } catch (Throwable e) { + JSONObject error = createFileTransferError(CONNECTION_ERR, source, target, connection); + Log.e(LOG_TAG, error.toString(), e); + context.sendPluginResult(new PluginResult(PluginResult.Status.IO_EXCEPTION, error)); + } finally { + synchronized (activeRequests) { + activeRequests.remove(objectId); + } + + if (connection != null) { + // Revert back to the proper verifier and socket factories + if (trustEveryone && url.getProtocol().toLowerCase().equals("https")) { + ((HttpsURLConnection) connection).setHostnameVerifier(defaultHostnameVerifier); + HttpsURLConnection.setDefaultSSLSocketFactory(defaultSSLSocketFactory); + } + + connection.disconnect(); + } } - - outputStream.close(); - - Log.d(LOG_TAG, "Saved file: " + target); - - // create FileEntry object - FileUtils fileUtil = new FileUtils(); - JSONObject fileEntry = fileUtil.getEntry(file); - - // Revert back to the proper verifier and socket factories - if (trustEveryone && url.getProtocol().toLowerCase().equals("https")) { - ((HttpsURLConnection) connection).setHostnameVerifier(defaultHostnameVerifier); - HttpsURLConnection.setDefaultSSLSocketFactory(defaultSSLSocketFactory); - } - - return new PluginResult(PluginResult.Status.OK, fileEntry); } - else - { - Log.w(LOG_TAG, "Source URL is not in white list: '" + source + "'"); - JSONObject error = createFileTransferError(CONNECTION_ERR, source, target, 401); - return new PluginResult(PluginResult.Status.IO_EXCEPTION, error); - } - - } catch (AbortException e) { - JSONObject error = createFileTransferError(ABORTED_ERR, source, target, connection); - return new PluginResult(PluginResult.Status.ERROR, error); - } catch (FileNotFoundException e) { - JSONObject error = createFileTransferError(FILE_NOT_FOUND_ERR, source, target, connection); - Log.d(LOG_TAG, "I got a file not found exception"); - Log.e(LOG_TAG, error.toString(), e); - return new PluginResult(PluginResult.Status.IO_EXCEPTION, error); - } catch (MalformedURLException e) { - JSONObject error = createFileTransferError(INVALID_URL_ERR, source, target, connection); - Log.e(LOG_TAG, error.toString(), e); - return new PluginResult(PluginResult.Status.IO_EXCEPTION, error); - } catch (Exception e) { // IOException, JSONException, NullPointer - JSONObject error = createFileTransferError(CONNECTION_ERR, source, target, connection); - Log.e(LOG_TAG, error.toString(), e); - return new PluginResult(PluginResult.Status.IO_EXCEPTION, error); - } finally { - if (connection != null) { - connection.disconnect(); - } - } + }); } /** @@ -727,20 +795,29 @@ public class FileTransfer extends CordovaPlugin { /** * Abort an ongoing upload or download. - * - * @param args args */ - private PluginResult abort(JSONArray args) { - String objectId; - try { - objectId = args.getString(0); - } catch (JSONException e) { - Log.d(LOG_TAG, "Missing objectId"); - return new PluginResult(PluginResult.Status.JSON_EXCEPTION, "Missing objectId"); + private void abort(String objectId) { + final RequestContext context; + synchronized (activeRequests) { + context = activeRequests.remove(objectId); } - synchronized (abortTriggered) { - abortTriggered.add(objectId); + if (context != null) { + // Trigger the abort callback immediately to minimize latency between it and abort() being called. + JSONObject error = createFileTransferError(ABORTED_ERR, context.source, context.target, -1); + synchronized (context) { + context.sendPluginResult(new PluginResult(PluginResult.Status.ERROR, error)); + context.aborted = true; + } + // Closing the streams can block, so execute on a background thread. + cordova.getThreadPool().execute(new Runnable() { + @Override + public void run() { + synchronized (context) { + safeClose(context.currentInputStream); + safeClose(context.currentOutputStream); + } + } + }); } - return new PluginResult(PluginResult.Status.OK); } }