diff --git a/README.md b/README.md index 37830e5..1082bc6 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,16 @@ The plugin uses [NSURLSession with background session configuration for iOS](htt tns plugin add nativescript-background-http ``` +## Breaking Change +In v5.0 `.uploadFile` and `.multipartUpload` returns a promise with the task instead of a task directly. +This is to allow ios "assets-library" urls to be uploaded (& easily in future ios PHAsset urls). + + ## Usage The below attached code snippets demonstrate how to use `nativescript-background-http` to upload single or multiple files. + ### Uploading files Sample code for configuring the upload session. Each session must have a unique `id`, but it can have multiple tasks running simultaneously. The `id` is passed as a parameter when creating the session (the `image-upload` string in the code bellow): @@ -23,14 +29,14 @@ Sample code for configuring the upload session. Each session must have a unique ```JavaScript // file path and url -var file = "/some/local/file/path/and/file/name.jpg"; -var url = "https://some.remote.service.com/path"; -var name = file.substr(file.lastIndexOf("/") + 1); +const file = "/some/local/file/path/and/file/name.jpg"; +const url = "https://some.remote.service.com/path"; +const name = file.substr(file.lastIndexOf("/") + 1); // upload configuration -var bghttp = require("nativescript-background-http"); -var session = bghttp.session("image-upload"); -var request = { +const bghttp = require("nativescript-background-http"); +const session = bghttp.session("image-upload"); +const request = { url: url, method: "POST", headers: { @@ -43,23 +49,28 @@ var request = { For a single file upload, use the following code: ```JavaScript -var task = session.uploadFile(file, request); +session.uploadFile(file, request).then( (task) => { /* Do something with Task */ }); ``` For multiple files or to pass additional data, use the multipart upload method. All parameter values must be strings: ```JavaScript -var params = [ +const params = [ { name: "test", value: "value" }, { name: "fileToUpload", filename: file, mimeType: "image/jpeg" } ]; -var task = session.multipartUpload(params, request); +session.multipartUpload(params, request).then( (task) => { /* Do something with Task */ } ); ``` In order to have a successful upload, the following must be taken into account: - the file must be accessible from your app. This may require additional permissions (e.g. access documents and files on the device). Usually this is not a problem - e.g. if you use another plugin to select the file, which already adds the required permissions. - the URL must not be blocked by the OS. Android Pie or later devices require TLS (HTTPS) connection by default and will not upload to an insecure (HTTP) URL. +- If you are going to upload or allow uploading assets-library urls on iOS (i.e. URL's received from the Gallery) you need to add the following to your ios's Info.plist file: +``` +NSPhotoLibraryUsageDescription^M +Requires access to photo library.^M +``` ### Upload request and task API @@ -120,7 +131,7 @@ function progressHandler(e) { // response: net.gotev.uploadservice.ServerResponse (Android) / NSHTTPURLResponse (iOS) function errorHandler(e) { alert("received " + e.responseCode + " code."); - var serverResponse = e.response; + let serverResponse = e.response; } @@ -138,7 +149,7 @@ function respondedHandler(e) { // response: net.gotev.uploadservice.ServerResponse (Android) / NSHTTPURLResponse (iOS) function completeHandler(e) { alert("received " + e.responseCode + " code"); - var serverResponse = e.response; + let serverResponse = e.response; } // event arguments: diff --git a/demo-angular/app/App_Resources/iOS/Info.plist b/demo-angular/app/App_Resources/iOS/Info.plist index 551c9cc..05ec38e 100644 --- a/demo-angular/app/App_Resources/iOS/Info.plist +++ b/demo-angular/app/App_Resources/iOS/Info.plist @@ -48,5 +48,7 @@ NSAllowsArbitraryLoads + NSPhotoLibraryUsageDescription + Requires access to photo library. diff --git a/demo-angular/app/home/home.component.ts b/demo-angular/app/home/home.component.ts index 940ab46..f19be23 100644 --- a/demo-angular/app/home/home.component.ts +++ b/demo-angular/app/home/home.component.ts @@ -63,7 +63,7 @@ export class HomeComponent { request.headers["Should-Fail"] = true; } - let task: bgHttp.Task; + let taskPromise: Promise; let lastEvent = ""; if (isMulti) { const params = [ @@ -72,9 +72,9 @@ export class HomeComponent { { name: "bool", value: true }, { name: "fileToUpload", filename: this.file, mimeType: 'image/jpeg' } ]; - task = this.session.multipartUpload(params, request); + taskPromise = this.session.multipartUpload(params, request); } else { - task = this.session.uploadFile(this.file, request); + taskPromise = this.session.uploadFile(this.file, request); } function onEvent(e) { @@ -97,11 +97,14 @@ export class HomeComponent { }); } - task.on("progress", onEvent.bind(this)); - task.on("error", onEvent.bind(this)); - task.on("responded", onEvent.bind(this)); - task.on("complete", onEvent.bind(this)); - lastEvent = ""; - this.tasks.push(task); + taskPromise.then( (task: bgHttp.Task) => + { + task.on("progress", onEvent.bind(this)); + task.on("error", onEvent.bind(this)); + task.on("responded", onEvent.bind(this)); + task.on("complete", onEvent.bind(this)); + lastEvent = ""; + this.tasks.push(task); + }); } } diff --git a/demo-server/package.json b/demo-server/package.json old mode 100644 new mode 100755 index 7dcc84a..f9663c8 --- a/demo-server/package.json +++ b/demo-server/package.json @@ -5,6 +5,7 @@ "start": "npm i && node server.js 8080" }, "dependencies": { + "formidable": "^1.2.2", "stream-throttle": "*" } } diff --git a/demo-server/server.js b/demo-server/server.js old mode 100644 new mode 100755 index 7854c24..a9536df --- a/demo-server/server.js +++ b/demo-server/server.js @@ -1,6 +1,7 @@ var http = require("http"); var fs = require("fs"); var path = require("path"); +var formidable = require('formidable'); function start(port, logger) { @@ -9,13 +10,33 @@ function start(port, logger) { try { var Throttle = require("stream-throttle").Throttle; + if (request.headers["content-type"] && request.headers["content-type"].startsWith("multipart/form-data")) { + const form = formidable({ multiples: true, uploadDir: outDir, keepExtensions: true }); + form.parse(request, (err, fields, files) => { + + // Make the files array look nicer... + let uploads={}; + for (let key in files) { + uploads[key] = files[key].path; + } + console.log("Fields", fields, "Files:", uploads); + logger.log("Done!"); + + var body = "Upload complete!"; + response.writeHead(200, "Done!", { "Content-Type": "text/plain", "Content-Length": body.length }); + response.write(body); + response.end(); + }); + return; + } + var fileName = request.headers["file-name"]; if (logger) { logger.log(request.method + "Request! Content-Length: " + request.headers["content-length"] + ", file-name: " + fileName); logger.dir(request.headers); } - var out = path.join(outDir, "upload-" + new Date().getTime() + "-" + fileName); + var out = path.join(outDir, "upload-" + new Date().getTime() ); if (logger) { logger.log("Output in: " + out); } @@ -26,7 +47,7 @@ function start(port, logger) { var shouldFail = request.headers["should-fail"]; // throttle write speed to 4MB/s - request.pipe(new Throttle({ rate: 1024 * 4096 })).pipe(fs.createWriteStream(out, { flags: 'w', encoding: null, fd: null, mode: 0666 })); + request.pipe(new Throttle({ rate: 1024 * 8192 })).pipe(fs.createWriteStream(out, { flags: 'w', encoding: null, fd: null, mode: 0666 })); request.on('data', function(chunk) { current += chunk.length; @@ -45,7 +66,7 @@ function start(port, logger) { } } else { if (logger) { - logger.log("Data [" + out + "]: " + current + " / " + total + " " + Math.floor(100 * current / total) + "%"); + //logger.log("Data [" + out + "]: " + current + " / " + total + " " + Math.floor(100 * current / total) + "%"); } } }); @@ -93,4 +114,6 @@ exports.start = start; if (process.argv.length === 3) { var port = parseInt(process.argv[2]); start(port, console); +} else { + console.log("Args", process.argv.length); } diff --git a/demo-vue/app/App_Resources/iOS/Info.plist b/demo-vue/app/App_Resources/iOS/Info.plist index 983be97..b3289ba 100644 --- a/demo-vue/app/App_Resources/iOS/Info.plist +++ b/demo-vue/app/App_Resources/iOS/Info.plist @@ -52,5 +52,7 @@ NSAllowsArbitraryLoads + NSPhotoLibraryUsageDescription + Requires access to photo library. diff --git a/demo-vue/app/components/Home.vue b/demo-vue/app/components/Home.vue index cdd6984..3eea3d9 100644 --- a/demo-vue/app/components/Home.vue +++ b/demo-vue/app/components/Home.vue @@ -87,7 +87,7 @@ export default { request.headers["Should-Fail"] = true; } - let task; // bgHttp.Task; + let taskPromise; // Promise let lastEvent = ""; if (isMulti) { @@ -97,9 +97,9 @@ export default { { name: "bool", value: true }, { name: "fileToUpload", filename: this.file, mimeType: 'image/jpeg' } ]; - task = this.session.multipartUpload(params, request); + taskPromise = this.session.multipartUpload(params, request); } else { - task = this.session.uploadFile(this.file, request); + taskPromise = this.session.uploadFile(this.file, request); } function onEvent(e) { @@ -124,13 +124,15 @@ export default { this.$set(this.tasks, this.tasks.indexOf(task), task); } - task.on("progress", onEvent.bind(this)); - task.on("error", onEvent.bind(this)); - task.on("responded", onEvent.bind(this)); - task.on("complete", onEvent.bind(this)); - lastEvent = ""; + taskPromise.then( (task) => { + task.on("progress", onEvent.bind(this)); + task.on("error", onEvent.bind(this)); + task.on("responded", onEvent.bind(this)); + task.on("complete", onEvent.bind(this)); + lastEvent = ""; + this.tasks.push(task); + }); - this.tasks.push(task); }, onItemLoading(args) { let label = args.view.getViewById("imageLabel"); diff --git a/demo/app/App_Resources/iOS/Info.plist b/demo/app/App_Resources/iOS/Info.plist old mode 100644 new mode 100755 index 551c9cc..05ec38e --- a/demo/app/App_Resources/iOS/Info.plist +++ b/demo/app/App_Resources/iOS/Info.plist @@ -48,5 +48,7 @@ NSAllowsArbitraryLoads + NSPhotoLibraryUsageDescription + Requires access to photo library. diff --git a/demo/app/home/home-view-model.ts b/demo/app/home/home-view-model.ts index 07fc797..4594303 100644 --- a/demo/app/home/home-view-model.ts +++ b/demo/app/home/home-view-model.ts @@ -63,7 +63,7 @@ export class HomeViewModel extends Observable { request.headers["Should-Fail"] = true; } - let task: bghttp.Task; + let taskPromise: Promise; let lastEvent = ""; if (isMulti) { const params = [ @@ -72,9 +72,9 @@ export class HomeViewModel extends Observable { { name: "bool", value: true }, { name: "fileToUpload", filename: this.file, mimeType: 'image/jpeg' } ]; - task = this.session.multipartUpload(params, request); + taskPromise = this.session.multipartUpload(params, request); } else { - task = this.session.uploadFile(this.file, request); + taskPromise = this.session.uploadFile(this.file, request); } function onEvent(e) { @@ -97,11 +97,13 @@ export class HomeViewModel extends Observable { }); } - task.on("progress", onEvent.bind(this)); - task.on("error", onEvent.bind(this)); - task.on("responded", onEvent.bind(this)); - task.on("complete", onEvent.bind(this)); - lastEvent = ""; - this.tasks.push(task); + taskPromise.then( (task: bghttp.Task ) => { + task.on("progress", onEvent.bind(this)); + task.on("error", onEvent.bind(this)); + task.on("responded", onEvent.bind(this)); + task.on("complete", onEvent.bind(this)); + lastEvent = ""; + this.tasks.push(task); + }); } } diff --git a/src/.npmignore b/src/.npmignore old mode 100644 new mode 100755 index 6ab38bf..b852129 --- a/src/.npmignore +++ b/src/.npmignore @@ -1,4 +1,5 @@ -scripts/* +scripts/ +typings/* *.map /node_modules *.ts @@ -7,4 +8,4 @@ tsconfig.json *.tgz /package /platforms/android/**/* -!platforms/android/uploadservice-release.aar \ No newline at end of file +!platforms/android/uploadservice-release.aar diff --git a/src/background-http.android.ts b/src/background-http.android.ts old mode 100644 new mode 100755 index 7ead237..fca0e84 --- a/src/background-http.android.ts +++ b/src/background-http.android.ts @@ -149,12 +149,12 @@ class Session { this._id = id; } - public uploadFile(fileUri: string, options: common.Request): Task { - return Task.create(this, fileUri, options); + public uploadFile(fileUri: string, options: common.Request): Promise { + return Promise.resolve( Task.create(this, fileUri, options) ); } - public multipartUpload(params: Array, options: common.Request): Task { - return Task.createMultiPart(this, params, options); + public multipartUpload(params: Array, options: common.Request): Promise { + return Promise.resolve( Task.createMultiPart(this, params, options) ); } get id(): string { diff --git a/src/background-http.ios.ts b/src/background-http.ios.ts old mode 100644 new mode 100755 index d40875a..2c0595c --- a/src/background-http.ios.ts +++ b/src/background-http.ios.ts @@ -53,6 +53,88 @@ function onError(session, nsTask, error) { } } +function getAssetData(asset: string, fileHandle: NSFileHandle = null): Promise { + let handle = fileHandle, opened = false; + const fileName = fileSystemModule.knownFolders.documents().path + "/temp-MPA-" + Math.floor(Math.random() * 100000000000) + ".tmp"; + if (fileHandle == null) { + NSFileManager.defaultManager.createFileAtPathContentsAttributes(fileName, null, null); + handle = NSFileHandle.fileHandleForWritingAtPath(fileName); + opened = true; + } else { + handle.seekToEndOfFile(); + } + if (!handle) { + return Promise.resolve(null); + } + + return new Promise(resolve => { + + // Get the Asset + PHPhotoLibrary.requestAuthorization( (status) => { + if (status !== PHAuthorizationStatus.Authorized) { + return resolve(null); + } + const nurl = NSURL.URLWithString(asset); + const assets = PHAsset.fetchAssetsWithALAssetURLsOptions(NSArray.arrayWithArray([nurl]), null); + + // No Asset matching via PHAsset System, try direct copy then... + // This path probably is NEVER hit, but for the sake of completeness, it is included... + if (assets.count == 0) { + let isWritten = false; + if (opened) { + handle.closeFile(); + opened = false; + } + try { + isWritten = NSFileManager.defaultManager.copyItemAtURLToURLError(nurl, NSURL.fileURLWithPath(fileName)); + } catch (err) { + // Do Nothing.... + } + return resolve(isWritten ? fileName : null); + } + + if (assets[0].mediaType === PHAssetMediaTypeImage) { + const options = PHImageRequestOptions.alloc().init(); + options.synchronous = true; + options.isNetworkAccessAllowed = true; + PHImageManager.defaultManager().requestImageDataForAssetOptionsResultHandler(assets[0], options, function (imageData, dataUTI, orientation, info) { + handle.writeData(imageData); + handle.synchronizeFile(); + if (opened) { + handle.closeFile(); + } + resolve(fileName); + }); + + } else if (assets[0].mediaType === PHAssetMediaTypeVideo) { + const options = PHVideoRequestOptions.alloc().init(); + options.version = PHVideoRequestOptionsVersionOriginal; + + PHImageManager.defaultManager().requestAVAssetForVideoOptionsResultHandler(assets[0], options, function (asset, audioMix, info) { + handle.writeData(NSData.dataWithContentsOfURL(asset.URL)); + handle.synchronizeFile(); + if (opened) { + handle.closeFile(); + } + resolve(fileName); + }); + } else { + if (opened) { + handle.closeFile(); + } + let fURL = NSURL.fileURLWithPath(fileName); + let written = NSFileManager.defaultManager.copyItemAtURLToURLError(nurl, fURL); + if (opened) { + opened = false; + } else { + handle.writeData(NSData.dataWithContentsOfURL(fURL)); + } + resolve(fileName); + } + }); + }); +} + class BackgroundUploadDelegate extends NSObject implements NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSessionDataDelegate, NSURLSessionDownloadDelegate { static ObjCProtocols = [NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSessionDataDelegate, NSURLSessionDownloadDelegate]; @@ -152,8 +234,7 @@ class Session implements common.Session { return this._session; } - - public uploadFile(fileUri: string, options: common.Request): common.Task { + public uploadFile(fileUri: string, options: common.Request): Promise { if (!fileUri) { throw new Error("File must be provided."); } @@ -176,48 +257,79 @@ class Session implements common.Session { } let fileURL: NSURL; - if (fileUri.substr(0, 7) === "file://") { - // File URI in string format - fileURL = NSURL.URLWithString(fileUri); - } else if (fileUri.charAt(0) === "/") { - // Absolute path with leading slash - fileURL = NSURL.fileURLWithPath(fileUri); - } - - const newTask = this._session.uploadTaskWithRequestFromFile(request, fileURL); - newTask.taskDescription = options.description; - newTask.resume(); - const retTask: common.Task = Task.getTask(this._session, newTask); - return retTask; - } - public multipartUpload(params: any[], options: any): common.Task { - const MPF = new MultiMultiPartForm(); - for (let i = 0; i < params.length; i++) { - const curParam = params[i]; - if (typeof curParam.name === 'undefined') { - throw new Error("You must have a `name` value"); + const handleUpload = (fileURL: string, deleteAfter: boolean, resolve) => { + console.log("In Handle", fileURL); + const newTask = this._session.uploadTaskWithRequestFromFile(request, fileURL); + newTask.taskDescription = options.description; + newTask.resume(); + const retTask: common.Task = Task.getTask(this._session, newTask); + if (deleteAfter) { + console.log("Setting Cleanup"); + (retTask)._fileToCleanup = fileURL; } - - if (curParam.filename) { - const destFileName = curParam.destFilename || curParam.filename.substring(curParam.filename.lastIndexOf('/') + 1, curParam.filename.length); - MPF.appendParam(curParam.name, null, curParam.filename, curParam.mimeType, destFileName); - } else { - MPF.appendParam(curParam.name, curParam.value); + console.log("Done"); + resolve(retTask); + }; + + return new Promise( (resolve) => { + if (fileUri.startsWith("file://")) { + // File URI in string format + fileURL = NSURL.URLWithString(fileUri); + handleUpload(fileURL, false, resolve); + } else if (fileUri.charAt(0) === "/") { + // Absolute path with leading slash + fileURL = NSURL.fileURLWithPath(fileUri); + handleUpload(fileURL, false, resolve); + } else if (fileUri.startsWith("assets-library://")) { + getAssetData(fileUri).then(fileName => { + console.log("Back from GAD", fileName); + if (fileName == null) { + return resolve(null); + } + fileURL = NSURL.fileURLWithPath(fileName); + handleUpload(fileURL, true, resolve); + }).catch(err => { + console.log("Error in uploadFile", err); + resolve(null); + }); } - } - const header = MPF.getHeader(); - const uploadFile = MPF.generateFile(); - if (!options.headers) { - options.headers = {}; - } - options.headers['Content-Type'] = header['Content-Type']; + }); + } - const task = this.uploadFile(uploadFile, options); + public multipartUpload(params: any[], options: any): Promise { + const MPF = new MultiMultiPartForm(); - // Tag the file to be deleted and cleanup after upload - (task)._fileToCleanup = uploadFile; - return task; + return new Promise( (resolve) => { + for (let i = 0; i < params.length; i++) { + const curParam = params[i]; + if (typeof curParam.name === 'undefined') { + throw new Error("You must have a `name` value"); + } + + if (curParam.filename) { + const destFileName = curParam.destFilename || curParam.filename.substring(curParam.filename.lastIndexOf('/') + 1, curParam.filename.length); + MPF.appendParam(curParam.name, null, curParam.filename, curParam.mimeType, destFileName); + } else { + MPF.appendParam(curParam.name, curParam.value); + } + } + const header = MPF.getHeader(); + MPF.generateFile().then( (uploadFileName: string) => { + if (!options.headers) { + options.headers = {}; + } + options.headers['Content-Type'] = header['Content-Type']; + + this.uploadFile(uploadFileName, options).then(task => { + // Tag the file to be deleted and cleanup after upload + (task)._fileToCleanup = uploadFileName; + resolve(task); + }); + }).catch((err) => { + console.log("Error", err, err.stack); + }); + }); } static getSession(id: string): common.Session { let jsSession = Session._sessions[id]; @@ -266,6 +378,7 @@ class Task extends Observable { public _fileToCleanup: string; private _task: NSURLSessionTask; private _session: NSURLSession; + private _canceled: boolean = false; constructor(nsSession: NSURLSession, nsTask: NSURLSessionTask) { super(); @@ -357,54 +470,60 @@ class MultiMultiPartForm { this.fields.push({ name: name, filename: filename, destFilename: finalName, mimeType: mimeType }); } - public generateFile(): string { + private _appendStringData(stringData: string, fileHandle: NSFileHandle) { + const tempString = NSString.stringWithString(stringData); + const newData = tempString.dataUsingEncoding(NSUTF8StringEncoding); + fileHandle.writeData(newData); + } + + public generateFile(): Promise { const CRLF = "\r\n"; const fileName = fileSystemModule.knownFolders.documents().path + "/temp-MPF-" + Math.floor(Math.random() * 100000000000) + ".tmp"; - - const combinedData = NSMutableData.alloc().init(); - - let results: string = ""; - let tempString: NSString; - let newData: any; - for (let i = 0; i < this.fields.length; i++) { - results += "--" + this.boundary + CRLF; - results += 'Content-Disposition: form-data; name="' + this.fields[i].name + '"'; - if (!this.fields[i].filename) { - results += CRLF + CRLF + this.fields[i].value + CRLF; - } else { - results += '; filename="' + this.fields[i].destFilename + '"'; - if (this.fields[i].mimeType) { - results += CRLF + "Content-Type: " + this.fields[i].mimeType; + NSFileManager.defaultManager.createFileAtPathContentsAttributes(fileName, null, null); + let handle = NSFileHandle.fileHandleForWritingAtPath(fileName); + + return new Promise( async (resolve) => { + let results = ""; + for (let i = 0; i < this.fields.length; i++) { + results += "--" + this.boundary + CRLF; + results += 'Content-Disposition: form-data; name="' + this.fields[i].name + '"'; + if (!this.fields[i].filename) { + results += CRLF + CRLF + this.fields[i].value + CRLF; + } else { + results += '; filename="' + this.fields[i].destFilename + '"'; + if (this.fields[i].mimeType) { + results += CRLF + "Content-Type: " + this.fields[i].mimeType; + } + results += CRLF + CRLF; } - results += CRLF + CRLF; - } - - tempString = NSString.stringWithString(results); - results = ""; - newData = tempString.dataUsingEncoding(NSUTF8StringEncoding); - combinedData.appendData(newData); + this._appendStringData(results, handle); + results = ""; + + if (this.fields[i].filename) { + if (this.fields[i].filename.startsWith("assets-library://")) { + await getAssetData(this.fields[i].filename, handle); + } else { + const fileData = NSData.alloc().initWithContentsOfFile(this.fields[i].filename); + handle.writeData(fileData); + } + results = CRLF; + } - if (this.fields[i].filename) { - const fileData = NSData.alloc().initWithContentsOfFile(this.fields[i].filename); - combinedData.appendData(fileData); - results = CRLF; } + // Add final part of it... + results += "--" + this.boundary + "--" + CRLF; + this._appendStringData(results, handle); + handle.closeFile(); - } - // Add final part of it... - results += "--" + this.boundary + "--" + CRLF; - tempString = NSString.stringWithString(results); - newData = tempString.dataUsingEncoding(NSUTF8StringEncoding); - combinedData.appendData(newData); - - NSFileManager.defaultManager.createFileAtPathContentsAttributes(fileName, combinedData, null); - - return fileName; + resolve(fileName); + }); } public getHeader(): string { return this.header; } } + + diff --git a/src/index.d.ts b/src/index.d.ts old mode 100644 new mode 100755 index 9e66108..a99300e --- a/src/index.d.ts +++ b/src/index.d.ts @@ -135,8 +135,8 @@ export interface Session { * @param fileUri A file path to upload. * @param options Options for the upload, sets uri, headers, task description etc. */ - uploadFile(fileUri: string, options: Request): Task; - multipartUpload(params: Array, options: Request): Task; + uploadFile(fileUri: string, options: Request): Promise; + multipartUpload(params: Array, options: Request): Promise; }