diff --git a/filesystem/README.md b/filesystem/README.md index 29493a0c8..4be7e186e 100644 --- a/filesystem/README.md +++ b/filesystem/README.md @@ -101,6 +101,8 @@ const readFilePath = async () => { * [`copy(...)`](#copy) * [`checkPermissions()`](#checkpermissions) * [`requestPermissions()`](#requestpermissions) +* [`downloadFile(...)`](#downloadfile) +* [`addListener('progress', ...)`](#addlistenerprogress) * [Interfaces](#interfaces) * [Type Aliases](#type-aliases) * [Enums](#enums) @@ -343,6 +345,45 @@ Required on Android, only when using `Directory.Documents`< -------------------- +### downloadFile(...) + +```typescript +downloadFile(options: DownloadFileOptions) => Promise +``` + +Perform a http request to a server and download the file to the specified destination. + +| Param | Type | +| ------------- | ------------------------------------------------------------------- | +| **`options`** | DownloadFileOptions | + +**Returns:** Promise<DownloadFileResult> + +**Since:** 5.1.0 + +-------------------- + + +### addListener('progress', ...) + +```typescript +addListener(eventName: 'progress', listenerFunc: ProgressListener) => Promise & PluginListenerHandle +``` + +Add a listener to file download progress events. + +| Param | Type | +| ------------------ | ------------------------------------------------------------- | +| **`eventName`** | 'progress' | +| **`listenerFunc`** | ProgressListener | + +**Returns:** Promise<PluginListenerHandle> & PluginListenerHandle + +**Since:** 5.1.0 + +-------------------- + + ### Interfaces @@ -501,6 +542,39 @@ Required on Android, only when using `Directory.Documents`< | **`publicStorage`** | PermissionState | +#### DownloadFileResult + +| Prop | Type | Description | Since | +| ---------- | ------------------- | -------------------------------------------------------------------- | ----- | +| **`path`** | string | The path the file was downloaded to. | 5.1.0 | +| **`blob`** | Blob | The blob data of the downloaded file. This is only available on web. | 5.1.0 | + + +#### DownloadFileOptions + +| Prop | Type | Description | Since | +| --------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`path`** | string | The path the downloaded file should be moved to. | 5.1.0 | +| **`directory`** | Directory | The directory to write the file to. If this option is used, filePath can be a relative path rather than absolute. The default is the `DATA` directory. | 5.1.0 | +| **`progress`** | boolean | An optional listener function to receive downloaded progress events. If this option is used, progress event should be dispatched on every chunk received. Chunks are throttled to every 100ms on Android/iOS to avoid slowdowns. | 5.1.0 | + + +#### PluginListenerHandle + +| Prop | Type | +| ------------ | ----------------------------------------- | +| **`remove`** | () => Promise<void> | + + +#### ProgressStatus + +| Prop | Type | Description | Since | +| ------------------- | ------------------- | ---------------------------------------------------- | ----- | +| **`url`** | string | The url of the file being downloaded. | 5.1.0 | +| **`bytes`** | number | The number of bytes downloaded so far. | 5.1.0 | +| **`contentLength`** | number | The total number of bytes to download for this file. | 5.1.0 | + + ### Type Aliases @@ -514,6 +588,13 @@ Required on Android, only when using `Directory.Documents`< 'prompt' | 'prompt-with-rationale' | 'granted' | 'denied' +#### ProgressListener + +A listener function that receives progress events. + +(progress: ProgressStatus): void + + ### Enums diff --git a/filesystem/android/src/main/java/com/capacitorjs/plugins/filesystem/Filesystem.java b/filesystem/android/src/main/java/com/capacitorjs/plugins/filesystem/Filesystem.java index 6eee93971..528e23d2a 100644 --- a/filesystem/android/src/main/java/com/capacitorjs/plugins/filesystem/Filesystem.java +++ b/filesystem/android/src/main/java/com/capacitorjs/plugins/filesystem/Filesystem.java @@ -7,10 +7,27 @@ import com.capacitorjs.plugins.filesystem.exceptions.CopyFailedException; import com.capacitorjs.plugins.filesystem.exceptions.DirectoryExistsException; import com.capacitorjs.plugins.filesystem.exceptions.DirectoryNotFoundException; -import java.io.*; +import com.getcapacitor.Bridge; +import com.getcapacitor.JSObject; +import com.getcapacitor.PluginCall; +import com.getcapacitor.plugin.util.CapacitorHttpUrlConnection; +import com.getcapacitor.plugin.util.HttpRequestHandler; +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.net.URISyntaxException; +import java.net.URL; import java.nio.channels.FileChannel; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.Locale; +import org.json.JSONException; public class Filesystem { @@ -285,4 +302,82 @@ public void copyRecursively(File src, File dst) throws IOException { destination.transferFrom(source, 0, source.size()); } } + + public JSObject downloadFile(PluginCall call, Bridge bridge, HttpRequestHandler.ProgressEmitter emitter) + throws IOException, URISyntaxException, JSONException { + String urlString = call.getString("url", ""); + JSObject headers = call.getObject("headers", new JSObject()); + JSObject params = call.getObject("params", new JSObject()); + Integer connectTimeout = call.getInt("connectTimeout"); + Integer readTimeout = call.getInt("readTimeout"); + Boolean disableRedirects = call.getBoolean("disableRedirects"); + Boolean shouldEncode = call.getBoolean("shouldEncodeUrlParams", true); + Boolean progress = call.getBoolean("progress", false); + + String method = call.getString("method", "GET").toUpperCase(Locale.ROOT); + String path = call.getString("path"); + String directory = call.getString("directory", Environment.DIRECTORY_DOWNLOADS); + + final URL url = new URL(urlString); + final File file = getFileObject(path, directory); + + HttpRequestHandler.HttpURLConnectionBuilder connectionBuilder = new HttpRequestHandler.HttpURLConnectionBuilder() + .setUrl(url) + .setMethod(method) + .setHeaders(headers) + .setUrlParams(params, shouldEncode) + .setConnectTimeout(connectTimeout) + .setReadTimeout(readTimeout) + .setDisableRedirects(disableRedirects) + .openConnection(); + + CapacitorHttpUrlConnection connection = connectionBuilder.build(); + + connection.setSSLSocketFactory(bridge); + + InputStream connectionInputStream = connection.getInputStream(); + FileOutputStream fileOutputStream = new FileOutputStream(file, false); + + String contentLength = connection.getHeaderField("content-length"); + int bytes = 0; + int maxBytes = 0; + + try { + maxBytes = contentLength != null ? Integer.parseInt(contentLength) : 0; + } catch (NumberFormatException ignored) {} + + byte[] buffer = new byte[1024]; + int len; + + // Throttle emitter to 100ms so it doesn't slow down app + long lastEmitTime = System.currentTimeMillis(); + long minEmitIntervalMillis = 100; + + while ((len = connectionInputStream.read(buffer)) > 0) { + fileOutputStream.write(buffer, 0, len); + + bytes += len; + + if (progress && null != emitter) { + long currentTime = System.currentTimeMillis(); + if (currentTime - lastEmitTime > minEmitIntervalMillis) { + emitter.emit(bytes, maxBytes); + lastEmitTime = currentTime; + } + } + } + + if (progress && null != emitter) { + emitter.emit(bytes, maxBytes); + } + + connectionInputStream.close(); + fileOutputStream.close(); + + return new JSObject() { + { + put("path", file.getAbsolutePath()); + } + }; + } } diff --git a/filesystem/android/src/main/java/com/capacitorjs/plugins/filesystem/FilesystemPlugin.java b/filesystem/android/src/main/java/com/capacitorjs/plugins/filesystem/FilesystemPlugin.java index 33fbea96d..77d4c31ef 100644 --- a/filesystem/android/src/main/java/com/capacitorjs/plugins/filesystem/FilesystemPlugin.java +++ b/filesystem/android/src/main/java/com/capacitorjs/plugins/filesystem/FilesystemPlugin.java @@ -4,6 +4,7 @@ import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; +import android.os.Environment; import com.capacitorjs.plugins.filesystem.exceptions.CopyFailedException; import com.capacitorjs.plugins.filesystem.exceptions.DirectoryExistsException; import com.capacitorjs.plugins.filesystem.exceptions.DirectoryNotFoundException; @@ -17,7 +18,10 @@ import com.getcapacitor.annotation.CapacitorPlugin; import com.getcapacitor.annotation.Permission; import com.getcapacitor.annotation.PermissionCallback; -import java.io.*; +import com.getcapacitor.plugin.util.HttpRequestHandler; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.attribute.BasicFileAttributes; @@ -376,6 +380,31 @@ public void copy(PluginCall call) { this._copy(call, false); } + @PluginMethod + public void downloadFile(PluginCall call) { + try { + String directory = call.getString("directory", Environment.DIRECTORY_DOWNLOADS); + + if (isPublicDirectory(directory) && !isStoragePermissionGranted()) { + requestAllPermissions(call, "permissionCallback"); + } else { + HttpRequestHandler.ProgressEmitter emitter = (bytes, contentLength) -> { + JSObject ret = new JSObject(); + ret.put("url", call.getString("url")); + ret.put("bytes", bytes); + ret.put("contentLength", contentLength); + + notifyListeners("progress", ret); + }; + + JSObject response = implementation.downloadFile(call, bridge, emitter); + call.resolve(response); + } + } catch (Exception ex) { + call.reject("Error downloading file: " + ex.getLocalizedMessage(), ex); + } + } + private void _copy(PluginCall call, Boolean doRename) { String from = call.getString("from"); String to = call.getString("to"); @@ -448,6 +477,9 @@ private void permissionCallback(PluginCall call) { case "stat": stat(call); break; + case "downloadFile": + downloadFile(call); + break; } } diff --git a/filesystem/ios/Plugin/Filesystem.swift b/filesystem/ios/Plugin/Filesystem.swift index 9adf0766f..bcd91b656 100644 --- a/filesystem/ios/Plugin/Filesystem.swift +++ b/filesystem/ios/Plugin/Filesystem.swift @@ -1,4 +1,5 @@ import Foundation +import Capacitor @objc public class Filesystem: NSObject { @@ -21,6 +22,8 @@ import Foundation } } + public typealias ProgressEmitter = (_ bytes: Int64, _ contentLength: Int64) -> Void + public func readFile(at fileUrl: URL, with encoding: String?) throws -> String { if encoding != nil { let data = try String(contentsOf: fileUrl, encoding: .utf8) @@ -176,4 +179,158 @@ import Foundation return URL(string: path) } } + + // swiftlint:disable function_body_length + @objc public func downloadFile(call: CAPPluginCall, emitter: @escaping ProgressEmitter, config: InstanceConfiguration?) throws { + let directory = call.getString("directory", "DOCUMENTS") + guard let path = call.getString("path") else { + call.reject("Invalid file path") + return + } + + func handleDownload(downloadLocation: URL?, response: URLResponse?, error: Error?) { + if let error = error { + CAPLog.print("Error on download file", String(describing: downloadLocation), String(describing: response), String(describing: error)) + call.reject(error.localizedDescription, "DOWNLOAD", error, nil) + return + } + + if let httpResponse = response as? HTTPURLResponse { + HttpRequestHandler.setCookiesFromResponse(httpResponse, config) + } + + guard let location = downloadLocation else { + call.reject("Unable to get file after downloading") + return + } + + let fileManager = FileManager.default + + if let foundDir = getDirectory(directory: directory) { + let dir = fileManager.urls(for: foundDir, in: .userDomainMask).first + + do { + let dest = dir!.appendingPathComponent(path) + CAPLog.print("Attempting to write to file destination: \(dest.absoluteString)") + + if !FileManager.default.fileExists(atPath: dest.deletingLastPathComponent().absoluteString) { + try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) + } + + if FileManager.default.fileExists(atPath: dest.relativePath) { + do { + CAPLog.print("File already exists. Attempting to remove file before writing.") + try fileManager.removeItem(at: dest) + } catch let error { + call.reject("Unable to remove existing file: \(error.localizedDescription)") + return + } + } + + try fileManager.moveItem(at: location, to: dest) + CAPLog.print("Downloaded file successfully! \(dest.absoluteString)") + call.resolve(["path": dest.absoluteString]) + } catch let error { + call.reject("Unable to download file: \(error.localizedDescription)", "DOWNLOAD", error) + return + } + } else { + call.reject("Unable to download file. Couldn't find directory \(directory)") + } + } + + guard var urlString = call.getString("url") else { throw URLError(.badURL) } + let method = call.getString("method", "GET") + + let headers = (call.getObject("headers") ?? [:]) as [String: Any] + let params = (call.getObject("params") ?? [:]) as [String: Any] + let responseType = call.getString("responseType", "text") + let connectTimeout = call.getDouble("connectTimeout") + let readTimeout = call.getDouble("readTimeout") + + if urlString == urlString.removingPercentEncoding { + guard let encodedUrlString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { throw URLError(.badURL) } + urlString = encodedUrlString + } + + let progress = call.getBool("progress", false) + + let request = try HttpRequestHandler.CapacitorHttpRequestBuilder() + .setUrl(urlString) + .setMethod(method) + .setUrlParams(params) + .openConnection() + .build() + + request.setRequestHeaders(headers) + + // Timeouts in iOS are in seconds. So read the value in millis and divide by 1000 + let timeout = (connectTimeout ?? readTimeout ?? 600000.0) / 1000.0 + request.setTimeout(timeout) + + if let data = call.options["data"] as? JSValue { + do { + try request.setRequestBody(data) + } catch { + // Explicitly reject if the http request body was not set successfully, + // so as to not send a known malformed request, and to provide the developer with additional context. + call.reject(error.localizedDescription, (error as NSError).domain, error, nil) + return + } + } + + var session: URLSession! + var task: URLSessionDownloadTask! + let urlRequest = request.getUrlRequest() + + if progress { + class ProgressDelegate: NSObject, URLSessionDataDelegate, URLSessionDownloadDelegate { + private var handler: (URL?, URLResponse?, Error?) -> Void + private var downloadLocation: URL? + private var response: URLResponse? + private var emitter: (Int64, Int64) -> Void + private var lastEmitTimestamp: TimeInterval = 0.0 + + init(downloadHandler: @escaping (URL?, URLResponse?, Error?) -> Void, progressEmitter: @escaping (Int64, Int64) -> Void) { + handler = downloadHandler + emitter = progressEmitter + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + let currentTimestamp = Date().timeIntervalSince1970 + let timeElapsed = currentTimestamp - lastEmitTimestamp + + if totalBytesExpectedToWrite > 0 { + if timeElapsed >= 0.1 { + emitter(totalBytesWritten, totalBytesExpectedToWrite) + lastEmitTimestamp = currentTimestamp + } + } else { + emitter(totalBytesWritten, 0) + lastEmitTimestamp = currentTimestamp + } + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + downloadLocation = location + handler(downloadLocation, downloadTask.response, downloadTask.error) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if error != nil { + handler(downloadLocation, task.response, error) + } + } + } + + let progressDelegate = ProgressDelegate(downloadHandler: handleDownload, progressEmitter: emitter) + session = URLSession(configuration: .default, delegate: progressDelegate, delegateQueue: nil) + task = session.downloadTask(with: urlRequest) + } else { + task = URLSession.shared.downloadTask(with: urlRequest, completionHandler: handleDownload) + } + + task.resume() + } + // swiftlint:enable function_body_length } diff --git a/filesystem/ios/Plugin/FilesystemPlugin.m b/filesystem/ios/Plugin/FilesystemPlugin.m index e4f00343d..d7c0adb60 100644 --- a/filesystem/ios/Plugin/FilesystemPlugin.m +++ b/filesystem/ios/Plugin/FilesystemPlugin.m @@ -17,4 +17,5 @@ CAP_PLUGIN_METHOD(copy, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(checkPermissions, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(requestPermissions, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(downloadFile, CAPPluginReturnPromise); ) diff --git a/filesystem/ios/Plugin/FilesystemPlugin.swift b/filesystem/ios/Plugin/FilesystemPlugin.swift index 62975b24e..406000cde 100644 --- a/filesystem/ios/Plugin/FilesystemPlugin.swift +++ b/filesystem/ios/Plugin/FilesystemPlugin.swift @@ -345,6 +345,23 @@ public class FilesystemPlugin: CAPPlugin { ]) } + @objc func downloadFile(_ call: CAPPluginCall) { + guard let url = call.getString("url") else { return call.reject("Must provide a URL") } + let progressEmitter: Filesystem.ProgressEmitter = { bytes, contentLength in + self.notifyListeners("progress", data: [ + "url": url, + "bytes": bytes, + "contentLength": contentLength + ]) + } + + do { + try implementation.downloadFile(call: call, emitter: progressEmitter, config: bridge?.config) + } catch let error { + call.reject(error.localizedDescription) + } + } + /** * Helper for handling errors */ diff --git a/filesystem/package.json b/filesystem/package.json index 155bff149..a5ef819f4 100644 --- a/filesystem/package.json +++ b/filesystem/package.json @@ -45,10 +45,10 @@ "publish:cocoapod": "pod trunk push ./CapacitorFilesystem.podspec --allow-warnings" }, "devDependencies": { - "@capacitor/android": "^5.0.0", - "@capacitor/core": "^5.0.0", + "@capacitor/android": "^5.1.1", + "@capacitor/core": "^5.1.1", "@capacitor/docgen": "0.2.0", - "@capacitor/ios": "^5.0.0", + "@capacitor/ios": "^5.1.1", "@ionic/eslint-config": "^0.3.0", "@ionic/prettier-config": "~1.0.1", "@ionic/swiftlint-config": "^1.1.2", @@ -61,7 +61,7 @@ "typescript": "~4.1.5" }, "peerDependencies": { - "@capacitor/core": "^5.0.0" + "@capacitor/core": "^5.1.1" }, "prettier": "@ionic/prettier-config", "swiftlint": "@ionic/swiftlint-config", diff --git a/filesystem/src/definitions.ts b/filesystem/src/definitions.ts index e8c21f8c4..fea786354 100644 --- a/filesystem/src/definitions.ts +++ b/filesystem/src/definitions.ts @@ -1,4 +1,8 @@ -import type { PermissionState } from '@capacitor/core'; +import type { + HttpOptions, + PermissionState, + PluginListenerHandle, +} from '@capacitor/core'; export interface PermissionStatus { publicStorage: PermissionState; @@ -474,6 +478,74 @@ export interface CopyResult { uri: string; } +export interface DownloadFileOptions extends HttpOptions { + /** + * The path the downloaded file should be moved to. + * + * @since 5.1.0 + */ + path: string; + /** + * The directory to write the file to. + * If this option is used, filePath can be a relative path rather than absolute. + * The default is the `DATA` directory. + * + * @since 5.1.0 + */ + directory?: Directory; + /** + * An optional listener function to receive downloaded progress events. + * If this option is used, progress event should be dispatched on every chunk received. + * Chunks are throttled to every 100ms on Android/iOS to avoid slowdowns. + * + * @since 5.1.0 + */ + progress?: boolean; +} + +export interface DownloadFileResult { + /** + * The path the file was downloaded to. + * + * @since 5.1.0 + */ + path?: string; + /** + * The blob data of the downloaded file. + * This is only available on web. + * + * @since 5.1.0 + */ + blob?: Blob; +} +export interface ProgressStatus { + /** + * The url of the file being downloaded. + * + * @since 5.1.0 + */ + url: string; + /** + * The number of bytes downloaded so far. + * + * @since 5.1.0 + */ + bytes: number; + /** + * The total number of bytes to download for this file. + * + * @since 5.1.0 + */ + contentLength: number; +} + +/** + * A listener function that receives progress events. + * + * @since 5.1.0 + */ +export type ProgressListener = (progress: ProgressStatus) => void; + export interface FilesystemPlugin { /** * Read a file from disk @@ -569,6 +641,23 @@ export interface FilesystemPlugin { * @since 1.0.0 */ requestPermissions(): Promise; + + /** + * Perform a http request to a server and download the file to the specified destination. + * + * @since 5.1.0 + */ + downloadFile(options: DownloadFileOptions): Promise; + + /** + * Add a listener to file download progress events. + * + * @since 5.1.0 + */ + addListener( + eventName: 'progress', + listenerFunc: ProgressListener, + ): Promise & PluginListenerHandle; } /** diff --git a/filesystem/src/web.ts b/filesystem/src/web.ts index 54ed8637c..229a5cf1e 100644 --- a/filesystem/src/web.ts +++ b/filesystem/src/web.ts @@ -1,4 +1,4 @@ -import { WebPlugin } from '@capacitor/core'; +import { WebPlugin, buildRequestInit } from '@capacitor/core'; import type { AppendFileOptions, @@ -21,6 +21,9 @@ import type { WriteFileOptions, WriteFileResult, Directory, + DownloadFileOptions, + DownloadFileResult, + ProgressStatus, } from './definitions'; import { Encoding } from './definitions'; @@ -642,6 +645,76 @@ export class FilesystemWeb extends WebPlugin implements FilesystemPlugin { }; } + /** + * Function that performs a http request to a server and downloads the file to the specified destination + * + * @param options the options for the download operation + * @returns a promise that resolves with the download file result + */ + public downloadFile = async ( + options: DownloadFileOptions, + ): Promise => { + const requestInit = buildRequestInit(options, options.webFetchExtra); + const response = await fetch(options.url, requestInit); + let blob: Blob; + + if (!options?.progress) blob = await response.blob(); + else if (!response?.body) blob = new Blob(); + else { + const reader = response.body.getReader(); + + let bytes = 0; + const chunks: (Uint8Array | undefined)[] = []; + + const contentType: string | null = response.headers.get('content-type'); + const contentLength: number = parseInt( + response.headers.get('content-length') || '0', + 10, + ); + + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + chunks.push(value); + bytes += value?.length || 0; + + const status: ProgressStatus = { + url: options.url, + bytes, + contentLength, + }; + + this.notifyListeners('progress', status); + } + + const allChunks = new Uint8Array(bytes); + let position = 0; + for (const chunk of chunks) { + if (typeof chunk === 'undefined') continue; + + allChunks.set(chunk, position); + position += chunk.length; + } + + blob = new Blob([allChunks.buffer], { type: contentType || undefined }); + } + + const blobUrl = URL.createObjectURL(blob); + const tempAnchor = document.createElement('a'); + document.body.appendChild(tempAnchor); + + tempAnchor.href = blobUrl; + tempAnchor.download = options.path; // This should be a filename, not a path + tempAnchor.click(); + + URL.revokeObjectURL(blobUrl); + document.body.removeChild(tempAnchor); + + return { path: options.path, blob }; + }; + private isBase64String(str: string): boolean { try { return btoa(atob(str)) == str; diff --git a/package.json b/package.json index 441c600ad..b560c1abd 100644 --- a/package.json +++ b/package.json @@ -31,5 +31,10 @@ "prettier": "~2.3.0", "prompts": "^2.3.2" }, - "prettier": "@ionic/prettier-config" + "prettier": "@ionic/prettier-config", + "swiftlint": { + "identifier_name": { + "min_length": 1 + } + } }