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
This commit is contained in:
daserge 2015-12-09 19:08:14 +03:00
parent a9470ff1cc
commit 182b0c5ebe
4 changed files with 340 additions and 92 deletions

View File

@ -74,7 +74,7 @@ multi-part POST or PUT request, and to download files as well.
__Parameters__: __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()`. - __server__: URL of the server to receive the file, as encoded by `encodeURI()`.

View File

@ -244,6 +244,11 @@ static CFIndex WriteDataToStream(NSData* data, CFWriteStreamRef stream)
int numChunks = sizeof(chunks) / sizeof(chunks[0]); int numChunks = sizeof(chunks) / sizeof(chunks[0]);
for (int i = 0; i < numChunks; ++i) { 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); CFIndex result = WriteDataToStream(chunks[i], writeStream);
if (result <= 0) { if (result <= 0) {
break; break;
@ -297,6 +302,28 @@ static CFIndex WriteDataToStream(NSData* data, CFWriteStreamRef stream)
NSString* server = [command argumentAtIndex:1]; NSString* server = [command argumentAtIndex:1];
NSError* __autoreleasing err = nil; 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]; CDVFilesystemURL *sourceURL = [CDVFilesystemURL fileSystemURLWithString:source];
NSObject<CDVFileSystem> *fs; NSObject<CDVFileSystem> *fs;
if (sourceURL) { if (sourceURL) {

View File

@ -31,6 +31,9 @@ var FTErr = require('./FileTransferError'),
var appData = Windows.Storage.ApplicationData.current; var appData = Windows.Storage.ApplicationData.current;
var LINE_START = "--";
var LINE_END = "\r\n";
var BOUNDARY = '+++++';
// Some private helper functions, hidden by the module // Some private helper functions, hidden by the module
function cordovaPathToNative(path) { function cordovaPathToNative(path) {
@ -54,6 +57,93 @@ function alreadyCancelled(opId) {
return op && op.state === FileTransferOperation.CANCELLED; 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 = []; var fileTransferOps = [];
function FileTransferOperation(state, promise) { function FileTransferOperation(state, promise) {
@ -73,7 +163,7 @@ module.exports = {
exec(win, fail, 'FileTransfer', 'upload', exec(win, fail, 'FileTransfer', 'upload',
[filePath, server, fileKey, fileName, mimeType, params, trustAllHosts, chunkedMode, headers, this._id, httpMethod]); [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 filePath = options[0];
var server = options[1]; var server = options[1];
var fileKey = options[2] || 'source'; var fileKey = options[2] || 'source';
@ -89,7 +179,129 @@ exec(win, fail, 'FileTransfer', 'upload',
var isMultipart = typeof headers["Content-Type"] === 'undefined'; var isMultipart = typeof headers["Content-Type"] === 'undefined';
if (!filePath || (typeof filePath !== 'string')) { 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; return;
} }
@ -103,6 +315,7 @@ exec(win, fail, 'FileTransfer', 'upload',
filePath = filePath.replace('cdvfile://localhost/persistent', appData.localFolder.path) filePath = filePath.replace('cdvfile://localhost/persistent', appData.localFolder.path)
.replace('cdvfile://localhost/temporary', appData.temporaryFolder.path); .replace('cdvfile://localhost/temporary', appData.temporaryFolder.path);
} }
// normalize path separators // normalize path separators
filePath = cordovaPathToNative(filePath); filePath = cordovaPathToNative(filePath);
@ -112,10 +325,10 @@ exec(win, fail, 'FileTransfer', 'upload',
Windows.Storage.StorageFile.getFileFromPathAsync(filePath) Windows.Storage.StorageFile.getFileFromPathAsync(filePath)
.then(function (storageFile) { .then(function (storageFile) {
if(!fileName) { if (!fileName) {
fileName = storageFile.name; fileName = storageFile.name;
} }
if(!mimeType) { if (!mimeType) {
// use the actual content type of the file, probably this should be the default way. // use the actual content type of the file, probably this should be the default way.
// other platforms probably can't look this up. // other platforms probably can't look this up.
mimeType = storageFile.contentType; mimeType = storageFile.contentType;
@ -168,90 +381,7 @@ exec(win, fail, 'FileTransfer', 'upload',
createUploadOperation.then( createUploadOperation.then(
function (upload) { function (upload) {
if (alreadyCancelled(uploadId)) { doUpload(upload, uploadId, filePath, server, successCallback, errorCallback);
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 });
}
);
}, },
function (err) { function (err) {
var errorObj = new FTErr(FTErr.INVALID_URL_ERR); var errorObj = new FTErr(FTErr.INVALID_URL_ERR);
@ -259,7 +389,7 @@ exec(win, fail, 'FileTransfer', 'upload',
errorCallback(errorObj); errorCallback(errorObj);
} }
); );
}, function(err) { }, function (err) {
errorCallback(new FTErr(FTErr.FILE_NOT_FOUND_ERR, fileName, server, null, null, 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 // 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 // otherwise the error callback will be hit during file creation in another place
FileProxy.resolveLocalFileSystemURI(successCallback, null, [nativeURI]); FileProxy.resolveLocalFileSystemURI(successCallback, null, [nativeURI]);
}, function(error) {
errorCallback(new FTErr(FTErr.FILE_NOT_FOUND_ERR, source, target, null, null, error));
}); });
}, function(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)); errorCallback(new FTErr(FTErr.FILE_NOT_FOUND_ERR, source, target, null, null, error));
}); });
}; };
var fileNotFoundErrorCallback = function(error) { var fileNotFoundErrorCallback = function(error) {
errorCallback(new FTErr(FTErr.FILE_NOT_FOUND_ERR, source, target, null, null, error)); errorCallback(new FTErr(FTErr.FILE_NOT_FOUND_ERR, source, target, null, null, error));
}; };

View File

@ -39,6 +39,9 @@ exports.defineAutoTests = function () {
var UPLOAD_TIMEOUT = 7 * ONE_SECOND; var UPLOAD_TIMEOUT = 7 * ONE_SECOND;
var ABORT_DELAY = 100; // for abort() tests var ABORT_DELAY = 100; // for abort() tests
var LATIN1_SYMBOLS = '¥§©ÆÖÑøøø¼'; 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 // config for upload test server
// NOTE: // NOTE:
@ -734,7 +737,7 @@ exports.defineAutoTests = function () {
} }
}); });
}, unexpectedCallbacks.httpFail); }, unexpectedCallbacks.httpFail);
}, DOWNLOAD_TIMEOUT); }, DOWNLOAD_TIMEOUT * 2);
it("filetransfer.spec.36 should handle non-UTF8 encoded download response", function (done) { 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 // NOTE: removing uploadOptions cause Android to timeout
transfer.upload(localFilePath, fileURL, uploadWin, unexpectedCallbacks.httpFail, uploadOptions); transfer.upload(localFilePath, fileURL, uploadWin, unexpectedCallbacks.httpFail, uploadOptions);
}, UPLOAD_TIMEOUT); }, 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);
}); });
}); });
}); });