Repository: cordova-plugin-file-transfer Updated Branches: refs/heads/master a9470ff1c -> 182b0c5eb
CB-9837 Add data URI support to file-transfer upload on iOS Adds iOS and Windows implementation; mention in the docs Adds corresponding tests Increases spec.35 timeout for Windows Phone 8.1 case as it contains 2 download operations Project: http://git-wip-us.apache.org/repos/asf/cordova-plugin-file-transfer/repo Commit: http://git-wip-us.apache.org/repos/asf/cordova-plugin-file-transfer/commit/182b0c5e Tree: http://git-wip-us.apache.org/repos/asf/cordova-plugin-file-transfer/tree/182b0c5e Diff: http://git-wip-us.apache.org/repos/asf/cordova-plugin-file-transfer/diff/182b0c5e Branch: refs/heads/master Commit: 182b0c5ebebd8c0d313c676de0f92b8a56a81cb1 Parents: a9470ff Author: daserge <[email protected]> Authored: Wed Dec 9 19:08:14 2015 +0300 Committer: daserge <[email protected]> Committed: Mon Dec 14 16:58:18 2015 +0300 ---------------------------------------------------------------------- README.md | 2 +- src/ios/CDVFileTransfer.m | 27 +++ src/windows/FileTransferProxy.js | 312 ++++++++++++++++++++++++---------- tests/tests.js | 91 +++++++++- 4 files changed, 340 insertions(+), 92 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/cordova-plugin-file-transfer/blob/182b0c5e/README.md ---------------------------------------------------------------------- diff --git a/README.md b/README.md index 8138fe0..9b10c60 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ multi-part POST or PUT request, and to download files as well. __Parameters__: -- __fileURL__: Filesystem URL representing the file on the device. For backwards compatibility, this can also be the full path of the file on the device. (See [Backwards Compatibility Notes] below) +- __fileURL__: Filesystem URL representing the file on the device or a [data: URI](https://en.wikipedia.org/wiki/Data_URI_scheme). For backwards compatibility, this can also be the full path of the file on the device. (See [Backwards Compatibility Notes](#backwards-compatibility-notes) below) - __server__: URL of the server to receive the file, as encoded by `encodeURI()`. http://git-wip-us.apache.org/repos/asf/cordova-plugin-file-transfer/blob/182b0c5e/src/ios/CDVFileTransfer.m ---------------------------------------------------------------------- diff --git a/src/ios/CDVFileTransfer.m b/src/ios/CDVFileTransfer.m index 45e83eb..4775a6d 100644 --- a/src/ios/CDVFileTransfer.m +++ b/src/ios/CDVFileTransfer.m @@ -244,6 +244,11 @@ static CFIndex WriteDataToStream(NSData* data, CFWriteStreamRef stream) int numChunks = sizeof(chunks) / sizeof(chunks[0]); for (int i = 0; i < numChunks; ++i) { + // Allow uploading of an empty file + if (chunks[i].length == 0) { + continue; + } + CFIndex result = WriteDataToStream(chunks[i], writeStream); if (result <= 0) { break; @@ -297,6 +302,28 @@ static CFIndex WriteDataToStream(NSData* data, CFWriteStreamRef stream) NSString* server = [command argumentAtIndex:1]; NSError* __autoreleasing err = nil; + if ([source hasPrefix:@"data:"] && [source rangeOfString:@"base64"].location != NSNotFound) { + NSRange commaRange = [source rangeOfString: @","]; + if (commaRange.location == NSNotFound) { + // Return error is there is no comma + __weak CDVFileTransfer* weakSelf = self; + CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsDictionary:[weakSelf createFileTransferError:INVALID_URL_ERR AndSource:source AndTarget:server]]; + [weakSelf.commandDelegate sendPluginResult:result callbackId:command.callbackId]; + return; + } + + if (commaRange.location + 1 > source.length - 1) { + // Init as an empty data + NSData *fileData = [[NSData alloc] init]; + [self uploadData:fileData command:command]; + return; + } + + NSData *fileData = [[NSData alloc] initWithBase64EncodedString:[source substringFromIndex:(commaRange.location + 1)] options:NSDataBase64DecodingIgnoreUnknownCharacters]; + [self uploadData:fileData command:command]; + return; + } + CDVFilesystemURL *sourceURL = [CDVFilesystemURL fileSystemURLWithString:source]; NSObject<CDVFileSystem> *fs; if (sourceURL) { http://git-wip-us.apache.org/repos/asf/cordova-plugin-file-transfer/blob/182b0c5e/src/windows/FileTransferProxy.js ---------------------------------------------------------------------- diff --git a/src/windows/FileTransferProxy.js b/src/windows/FileTransferProxy.js index f26502d..6065f76 100644 --- a/src/windows/FileTransferProxy.js +++ b/src/windows/FileTransferProxy.js @@ -31,6 +31,9 @@ var FTErr = require('./FileTransferError'), var appData = Windows.Storage.ApplicationData.current; +var LINE_START = "--"; +var LINE_END = "\r\n"; +var BOUNDARY = '+++++'; // Some private helper functions, hidden by the module function cordovaPathToNative(path) { @@ -54,6 +57,93 @@ function alreadyCancelled(opId) { return op && op.state === FileTransferOperation.CANCELLED; } +function doUpload (upload, uploadId, filePath, server, successCallback, errorCallback) { + if (alreadyCancelled(uploadId)) { + errorCallback(new FTErr(FTErr.ABORT_ERR, nativePathToCordova(filePath), server)); + return; + } + + // update internal TransferOperation object with newly created promise + var uploadOperation = upload.startAsync(); + fileTransferOps[uploadId].promise = uploadOperation; + + uploadOperation.then( + function (result) { + // Update TransferOperation object with new state, delete promise property + // since it is not actual anymore + var currentUploadOp = fileTransferOps[uploadId]; + if (currentUploadOp) { + currentUploadOp.state = FileTransferOperation.DONE; + currentUploadOp.promise = null; + } + + var response = result.getResponseInformation(); + var ftResult = new FileUploadResult(result.progress.bytesSent, response.statusCode, ''); + + // if server's response doesn't contain any data, then resolve operation now + if (result.progress.bytesReceived === 0) { + successCallback(ftResult); + return; + } + + // otherwise create a data reader, attached to response stream to get server's response + var reader = new Windows.Storage.Streams.DataReader(result.getResultStreamAt(0)); + reader.loadAsync(result.progress.bytesReceived).then(function (size) { + ftResult.response = reader.readString(size); + successCallback(ftResult); + reader.close(); + }); + }, + function (error) { + var source = nativePathToCordova(filePath); + + // Handle download error here. + // Wrap this routines into promise due to some async methods + var getTransferError = new WinJS.Promise(function (resolve) { + if (error.message === 'Canceled') { + // If download was cancelled, message property will be specified + resolve(new FTErr(FTErr.ABORT_ERR, source, server, null, null, error)); + } else { + // in the other way, try to get response property + var response = upload.getResponseInformation(); + if (!response) { + resolve(new FTErr(FTErr.CONNECTION_ERR, source, server)); + } else { + var reader = new Windows.Storage.Streams.DataReader(upload.getResultStreamAt(0)); + reader.loadAsync(upload.progress.bytesReceived).then(function (size) { + var responseText = reader.readString(size); + resolve(new FTErr(FTErr.FILE_NOT_FOUND_ERR, source, server, response.statusCode, responseText, error)); + reader.close(); + }); + } + } + }); + + // Update TransferOperation object with new state, delete promise property + // since it is not actual anymore + var currentUploadOp = fileTransferOps[uploadId]; + if (currentUploadOp) { + currentUploadOp.state = FileTransferOperation.CANCELLED; + currentUploadOp.promise = null; + } + + // Report the upload error back + getTransferError.then(function (transferError) { + errorCallback(transferError); + }); + }, + function (evt) { + var progressEvent = new ProgressEvent('progress', { + loaded: evt.progress.bytesSent, + total: evt.progress.totalBytesToSend, + target: evt.resultFile + }); + progressEvent.lengthComputable = true; + successCallback(progressEvent, { keepCallback: true }); + } + ); +} + var fileTransferOps = []; function FileTransferOperation(state, promise) { @@ -73,7 +163,7 @@ module.exports = { exec(win, fail, 'FileTransfer', 'upload', [filePath, server, fileKey, fileName, mimeType, params, trustAllHosts, chunkedMode, headers, this._id, httpMethod]); */ - upload:function(successCallback, errorCallback, options) { + upload: function (successCallback, errorCallback, options) { var filePath = options[0]; var server = options[1]; var fileKey = options[2] || 'source'; @@ -89,7 +179,129 @@ exec(win, fail, 'FileTransfer', 'upload', var isMultipart = typeof headers["Content-Type"] === 'undefined'; if (!filePath || (typeof filePath !== 'string')) { - errorCallback(new FTErr(FTErr.FILE_NOT_FOUND_ERR,null,server)); + errorCallback(new FTErr(FTErr.FILE_NOT_FOUND_ERR, null, server)); + return; + } + + if (filePath.indexOf("data:") === 0 && filePath.indexOf("base64") !== -1) { + // First a DataWriter object is created, backed by an in-memory stream where + // the data will be stored. + var writer = Windows.Storage.Streams.DataWriter(new Windows.Storage.Streams.InMemoryRandomAccessStream()); + writer.unicodeEncoding = Windows.Storage.Streams.UnicodeEncoding.utf8; + writer.byteOrder = Windows.Storage.Streams.ByteOrder.littleEndian; + + var commaIndex = filePath.indexOf(","); + if (commaIndex === -1) { + errorCallback(new FTErr(FTErr.INVALID_URL_ERR, fileName, server, null, null, "No comma in data: URI")); + return; + } + + // Create internal download operation object + fileTransferOps[uploadId] = new FileTransferOperation(FileTransferOperation.PENDING, null); + + var fileDataString = filePath.substr(commaIndex + 1); + + function stringToByteArray(str) { + var byteCharacters = atob(str); + var byteNumbers = new Array(byteCharacters.length); + for (var i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + return new Uint8Array(byteNumbers); + }; + + // setting request headers for uploader + var uploader = new Windows.Networking.BackgroundTransfer.BackgroundUploader(); + uploader.method = httpMethod; + for (var header in headers) { + if (headers.hasOwnProperty(header)) { + uploader.setRequestHeader(header, headers[header]); + } + } + + if (isMultipart) { + // adding params supplied to request payload + var multipartParams = ''; + for (var key in params) { + if (params.hasOwnProperty(key)) { + multipartParams += LINE_START + BOUNDARY + LINE_END; + multipartParams += "Content-Disposition: form-data; name=\"" + key + "\""; + multipartParams += LINE_END + LINE_END; + multipartParams += params[key]; + multipartParams += LINE_END; + } + } + + var multipartFile = LINE_START + BOUNDARY + LINE_END; + multipartFile += "Content-Disposition: form-data; name=\"file\";"; + multipartFile += " filename=\"" + fileName + "\"" + LINE_END; + multipartFile += "Content-Type: " + mimeType + LINE_END + LINE_END; + + var bound = LINE_END + LINE_START + BOUNDARY + LINE_END; + + uploader.setRequestHeader("Content-Type", "multipart/form-data; boundary=" + BOUNDARY); + writer.writeString(multipartParams); + writer.writeString(multipartFile); + writer.writeBytes(stringToByteArray(fileDataString)); + writer.writeString(bound); + } else { + writer.writeBytes(stringToByteArray(fileDataString)); + } + + var stream; + + // The call to store async sends the actual contents of the writer + // to the backing stream. + writer.storeAsync().then(function () { + // For the in-memory stream implementation we are using, the flushAsync call + // is superfluous, but other types of streams may require it. + return writer.flushAsync(); + }).then(function () { + // We detach the stream to prolong its useful lifetime. Were we to fail + // to detach the stream, the call to writer.close() would close the underlying + // stream, preventing its subsequent use by the DataReader below. Most clients + // of DataWriter will have no reason to use the underlying stream after + // writer.close() is called, and will therefore have no reason to call + // writer.detachStream(). Note that once we detach the stream, we assume + // responsibility for closing the stream subsequently; after the stream + // has been detached, a call to writer.close() will have no effect on the stream. + stream = writer.detachStream(); + // Make sure the stream is read from the beginning in the reader + // we are creating below. + stream.seek(0); + // Most DataWriter clients will not call writer.detachStream(), + // and furthermore will be working with a file-backed or network-backed stream, + // rather than an in-memory-stream. In such cases, it would be particularly + // important to call writer.close(). Doing so is always a best practice. + writer.close(); + + if (alreadyCancelled(uploadId)) { + errorCallback(new FTErr(FTErr.ABORT_ERR, nativePathToCordova(filePath), server)); + return; + } + + // create download object. This will throw an exception if URL is malformed + var uri = new Windows.Foundation.Uri(server); + + var createUploadOperation; + try { + createUploadOperation = uploader.createUploadFromStreamAsync(uri, stream); + } catch (e) { + errorCallback(new FTErr(FTErr.INVALID_URL_ERR)); + return; + } + + createUploadOperation.then( + function (upload) { + doUpload(upload, uploadId, filePath, server, successCallback, errorCallback); + }, + function (err) { + var errorObj = new FTErr(FTErr.INVALID_URL_ERR); + errorObj.exception = err; + errorCallback(errorObj); + }); + }); + return; } @@ -103,6 +315,7 @@ exec(win, fail, 'FileTransfer', 'upload', filePath = filePath.replace('cdvfile://localhost/persistent', appData.localFolder.path) .replace('cdvfile://localhost/temporary', appData.temporaryFolder.path); } + // normalize path separators filePath = cordovaPathToNative(filePath); @@ -112,10 +325,10 @@ exec(win, fail, 'FileTransfer', 'upload', Windows.Storage.StorageFile.getFileFromPathAsync(filePath) .then(function (storageFile) { - if(!fileName) { + if (!fileName) { fileName = storageFile.name; } - if(!mimeType) { + if (!mimeType) { // use the actual content type of the file, probably this should be the default way. // other platforms probably can't look this up. mimeType = storageFile.contentType; @@ -168,90 +381,7 @@ exec(win, fail, 'FileTransfer', 'upload', createUploadOperation.then( function (upload) { - if (alreadyCancelled(uploadId)) { - errorCallback(new FTErr(FTErr.ABORT_ERR, nativePathToCordova(filePath), server)); - return; - } - - // update internal TransferOperation object with newly created promise - var uploadOperation = upload.startAsync(); - fileTransferOps[uploadId].promise = uploadOperation; - - uploadOperation.then( - function (result) { - // Update TransferOperation object with new state, delete promise property - // since it is not actual anymore - var currentUploadOp = fileTransferOps[uploadId]; - if (currentUploadOp) { - currentUploadOp.state = FileTransferOperation.DONE; - currentUploadOp.promise = null; - } - - var response = result.getResponseInformation(); - var ftResult = new FileUploadResult(result.progress.bytesSent, response.statusCode, ''); - - // if server's response doesn't contain any data, then resolve operation now - if (result.progress.bytesReceived === 0) { - successCallback(ftResult); - return; - } - - // otherwise create a data reader, attached to response stream to get server's response - var reader = new Windows.Storage.Streams.DataReader(result.getResultStreamAt(0)); - reader.loadAsync(result.progress.bytesReceived).then(function (size) { - ftResult.response = reader.readString(size); - successCallback(ftResult); - reader.close(); - }); - }, - function (error) { - var source = nativePathToCordova(filePath); - - // Handle download error here. - // Wrap this routines into promise due to some async methods - var getTransferError = new WinJS.Promise(function(resolve) { - if (error.message === 'Canceled') { - // If download was cancelled, message property will be specified - resolve(new FTErr(FTErr.ABORT_ERR, source, server, null, null, error)); - } else { - // in the other way, try to get response property - var response = upload.getResponseInformation(); - if (!response) { - resolve(new FTErr(FTErr.CONNECTION_ERR, source, server)); - } else { - var reader = new Windows.Storage.Streams.DataReader(upload.getResultStreamAt(0)); - reader.loadAsync(upload.progress.bytesReceived).then(function (size) { - var responseText = reader.readString(size); - resolve(new FTErr(FTErr.FILE_NOT_FOUND_ERR, source, server, response.statusCode, responseText, error)); - reader.close(); - }); - } - } - }); - - // Update TransferOperation object with new state, delete promise property - // since it is not actual anymore - var currentUploadOp = fileTransferOps[uploadId]; - if (currentUploadOp) { - currentUploadOp.state = FileTransferOperation.CANCELLED; - currentUploadOp.promise = null; - } - - // Report the upload error back - getTransferError.then(function(transferError) { - errorCallback(transferError); - }); - }, - function (evt) { - var progressEvent = new ProgressEvent('progress', { - loaded: evt.progress.bytesSent, - total: evt.progress.totalBytesToSend, - target: evt.resultFile - }); - progressEvent.lengthComputable = true; - successCallback(progressEvent, { keepCallback: true }); - } - ); + doUpload(upload, uploadId, filePath, server, successCallback, errorCallback); }, function (err) { var errorObj = new FTErr(FTErr.INVALID_URL_ERR); @@ -259,7 +389,7 @@ exec(win, fail, 'FileTransfer', 'upload', errorCallback(errorObj); } ); - }, function(err) { + }, function (err) { errorCallback(new FTErr(FTErr.FILE_NOT_FOUND_ERR, fileName, server, null, null, err)); }); }, @@ -350,6 +480,8 @@ exec(win, fail, 'FileTransfer', 'upload', // Passing null as error callback here because downloaded file should exist in any case // otherwise the error callback will be hit during file creation in another place FileProxy.resolveLocalFileSystemURI(successCallback, null, [nativeURI]); + }, function(error) { + errorCallback(new FTErr(FTErr.FILE_NOT_FOUND_ERR, source, target, null, null, error)); }); }, function(error) { @@ -407,7 +539,7 @@ exec(win, fail, 'FileTransfer', 'upload', errorCallback(new FTErr(FTErr.FILE_NOT_FOUND_ERR, source, target, null, null, error)); }); }; - + var fileNotFoundErrorCallback = function(error) { errorCallback(new FTErr(FTErr.FILE_NOT_FOUND_ERR, source, target, null, null, error)); }; http://git-wip-us.apache.org/repos/asf/cordova-plugin-file-transfer/blob/182b0c5e/tests/tests.js ---------------------------------------------------------------------- diff --git a/tests/tests.js b/tests/tests.js index 05a745b..1a1ffbb 100644 --- a/tests/tests.js +++ b/tests/tests.js @@ -39,6 +39,9 @@ exports.defineAutoTests = function () { var UPLOAD_TIMEOUT = 7 * ONE_SECOND; var ABORT_DELAY = 100; // for abort() tests var LATIN1_SYMBOLS = '¥§©ÃÃÃøøø¼'; + var DATA_URI_PREFIX = "data:image/png;base64,"; + var DATA_URI_CONTENT = "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; + var DATA_URI_CONTENT_LENGTH = 85; // bytes. (This is the raw file size: used https://en.wikipedia.org/wiki/File:Red-dot-5px.png from https://en.wikipedia.org/wiki/Data_URI_scheme) // config for upload test server // NOTE: @@ -734,7 +737,7 @@ exports.defineAutoTests = function () { } }); }, unexpectedCallbacks.httpFail); - }, DOWNLOAD_TIMEOUT); + }, DOWNLOAD_TIMEOUT * 2); it("filetransfer.spec.36 should handle non-UTF8 encoded download response", function (done) { @@ -1148,6 +1151,92 @@ exports.defineAutoTests = function () { // NOTE: removing uploadOptions cause Android to timeout transfer.upload(localFilePath, fileURL, uploadWin, unexpectedCallbacks.httpFail, uploadOptions); }, UPLOAD_TIMEOUT); + + it("filetransfer.spec.38 should be able to upload a file using data: source uri", function (done) { + + var fileURL = SERVER + "/upload"; + + var uploadWin = function (uploadResult) { + + verifyUpload(uploadResult); + + var obj = null; + try { + obj = JSON.parse(uploadResult.response); + expect(obj.files.file.size).toBe(DATA_URI_CONTENT_LENGTH); + } catch (e) { + expect(obj).not.toBeNull("returned data from server should be valid json"); + } + + if (cordova.platformId === "ios") { + expect(uploadResult.headers).toBeDefined("Expected headers to be defined."); + expect(uploadResult.headers["Content-Type"]).toBeDefined("Expected content-type header to be defined."); + } + + done(); + }; + + var dataUri = DATA_URI_PREFIX + DATA_URI_CONTENT; + // NOTE: removing uploadOptions cause Android to timeout + transfer.upload(dataUri, fileURL, uploadWin, function (err) { + console.error('err: ' + JSON.stringify(err)); + expect(err).not.toBeDefined(); + done(); + }, uploadOptions); + }, UPLOAD_TIMEOUT); + + it("filetransfer.spec.39 should be able to upload a file using data: source uri (non-multipart)", function (done) { + + var fileURL = SERVER + "/upload"; + + var uploadWin = function (uploadResult) { + + expect(uploadResult.responseCode).toBe(200); + expect(uploadResult.bytesSent).toBeGreaterThan(0); + + if (cordova.platformId === "ios") { + expect(uploadResult.headers).toBeDefined("Expected headers to be defined."); + expect(uploadResult.headers["Content-Type"]).toBeDefined("Expected content-type header to be defined."); + } + + done(); + }; + + // Content-Type header disables multipart + uploadOptions.headers = { + "Content-Type": "image/png" + }; + + var dataUri = DATA_URI_PREFIX + DATA_URI_CONTENT; + // NOTE: removing uploadOptions cause Android to timeout + transfer.upload(dataUri, fileURL, uploadWin, unexpectedCallbacks.httpFail, uploadOptions); + }, UPLOAD_TIMEOUT); + + it("filetransfer.spec.40 should not fail to upload a file using data: source uri when the data is empty", function (done) { + + var fileURL = SERVER + "/upload"; + + var dataUri = DATA_URI_PREFIX; + // NOTE: removing uploadOptions cause Android to timeout + transfer.upload(dataUri, fileURL, done, unexpectedCallbacks.httpFail, uploadOptions); + }, UPLOAD_TIMEOUT); + + it("filetransfer.spec.41 should not fail to upload a file using data: source uri when the data is empty (non-multipart)", function (done) { + + var fileURL = SERVER + "/upload"; + + // Content-Type header disables multipart + uploadOptions.headers = { + "Content-Type": "image/png" + }; + + // turn off the onprogress handler + transfer.onprogress = function () { }; + + var dataUri = DATA_URI_PREFIX; + // NOTE: removing uploadOptions cause Android to timeout + transfer.upload(dataUri, fileURL, done, unexpectedCallbacks.httpFail, uploadOptions); + }, UPLOAD_TIMEOUT); }); }); }); --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
