From 76500c319f47787dc64ee2e20b7b16bc4c0cfebb Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Wed, 2 Mar 2022 17:15:06 -0800 Subject: [PATCH 01/14] Improved solution for iOS 14.5 >= --- .../Capacitor/WebViewDelegationHandler.swift | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index d0870ad1e..4121cd27b 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -68,6 +68,14 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat // post a notification for any listeners NotificationCenter.default.post(name: .capacitorDecidePolicyForNavigationAction, object: navigationAction) + // check if we can detect file download on iOS >= 14.5 + if #available(iOS 14.5, *) { + if (navigationAction.shouldPerformDownload) { + decisionHandler(.download) + return + } + } + // sanity check, these shouldn't ever be nil in practice guard let bridge = bridge, let navURL = navigationAction.request.url else { decisionHandler(.allow) @@ -155,6 +163,23 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat CAPLog.print("⚡️ Error: " + error.localizedDescription) } + // Make sure we do handle file downloads if webview can display it + public func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + //Check if webview can properly display the file + if navigationResponse.canShowMIMEType { + decisionHandler(.allow) + return; + } + //Download support for iOS >= 14.5 + if #available(iOS 14.5, *) { + decisionHandler(.download) + return + } + //Deny if not recognize until now and webView can not + //show the specified MIME type + decisionHandler(.cancel) + } + open func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { CAPLog.print("⚡️ WebView process terminated") bridge?.reset() @@ -312,6 +337,20 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat return nil } + @available(iOS 14.5, *) + public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { + //TODO: deal with conflicts + let documentURL = self.getUniqueFileURL(suggestedFilename, optionalSuffix: nil) + + CAPLog.print("⚡️ Writting download file to:", documentURL.absoluteString) + + completionHandler(documentURL) + } + @available(iOS 14.5, *) + func downloadDidFinish(_ download: WKDownload) { + CAPLog.print("⚡️ Download finished") + } + // MARK: - UIScrollViewDelegate // disable zooming in WKWebView ScrollView @@ -338,4 +377,14 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat CAPLog.print("⚡️ \(filename):\(line):\(col)") CAPLog.print("\n⚡️ See above for help with debugging blank-screen issues") } + + private func getUniqueFileURL(_ suggestedFilename: String, optionalSuffix: String?) -> URL { + let documentsFolderURL = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let documentURL = documentsFolderURL.appendingPathComponent(suggestedFilename + (optionalSuffix ?? ""), isDirectory: false) + if FileManager.default.fileExists(atPath: documentURL.absoluteString) { + let randSuffix = String((0..<35).map{ _ in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()! }) + return self.getUniqueFileURL(suggestedFilename, optionalSuffix: randSuffix) + } + return documentURL + } } From a2b9de22d82395912868bc4f58e2d6556205b4a5 Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Wed, 2 Mar 2022 18:21:41 -0800 Subject: [PATCH 02/14] Improvements to file writting (avoid collision and empty file name (missing headers)), add notifications at app level for file download statuses and path --- .../Capacitor/CAPNotifications.swift | 6 +++ .../Capacitor/WebViewDelegationHandler.swift | 51 +++++++++++++++---- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/ios/Capacitor/Capacitor/CAPNotifications.swift b/ios/Capacitor/Capacitor/CAPNotifications.swift index a79cbcee5..daa922690 100644 --- a/ios/Capacitor/Capacitor/CAPNotifications.swift +++ b/ios/Capacitor/Capacitor/CAPNotifications.swift @@ -9,6 +9,7 @@ extension Notification.Name { public static let capacitorOpenURL = Notification.Name(rawValue: "CapacitorOpenURLNotification") public static let capacitorOpenUniversalLink = Notification.Name(rawValue: "CapacitorOpenUniversalLinkNotification") public static let capacitorContinueActivity = Notification.Name(rawValue: "CapacitorContinueActivityNotification") + public static let capacitorDidReceiveFileDownloadUpdate = Notification.Name(rawValue: "CapacitorDidReceiveFileDownloadUpdateNotification") public static let capacitorDidRegisterForRemoteNotifications = Notification.Name(rawValue: "CapacitorDidRegisterForRemoteNotificationsNotification") public static let capacitorDidFailToRegisterForRemoteNotifications = @@ -18,10 +19,15 @@ extension Notification.Name { public static let capacitorStatusBarTapped = Notification.Name(rawValue: "CapacitorStatusBarTappedNotification") } +public enum FileDownloadNotificationStatus { + case started, completed, failed +} + @objc extension NSNotification { public static let capacitorOpenURL = Notification.Name.capacitorOpenURL public static let capacitorOpenUniversalLink = Notification.Name.capacitorOpenUniversalLink public static let capacitorContinueActivity = Notification.Name.capacitorContinueActivity + public static let capacitorDidReceiveFileDownloadUpdate = Notification.Name.capacitorDidReceiveFileDownloadUpdate public static let capacitorDidRegisterForRemoteNotifications = Notification.Name.capacitorDidRegisterForRemoteNotifications public static let capacitorDidFailToRegisterForRemoteNotifications = Notification.Name.capacitorDidFailToRegisterForRemoteNotifications public static let capacitorDecidePolicyForNavigationAction = Notification.Name.capacitorDecidePolicyForNavigationAction diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index 4121cd27b..e87f4d059 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -168,7 +168,7 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat //Check if webview can properly display the file if navigationResponse.canShowMIMEType { decisionHandler(.allow) - return; + return } //Download support for iOS >= 14.5 if #available(iOS 14.5, *) { @@ -339,16 +339,35 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat @available(iOS 14.5, *) public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { - //TODO: deal with conflicts - let documentURL = self.getUniqueFileURL(suggestedFilename, optionalSuffix: nil) - - CAPLog.print("⚡️ Writting download file to:", documentURL.absoluteString) - + // generate unique URL (user can download file with same names or filename not be available on the headers) + let documentURL = self.getUniqueDownloadFileURL(suggestedFilename, optionalSuffix: nil) + CAPLog.print("⚡️ Download path:", documentURL.absoluteString) completionHandler(documentURL) + // notify + NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ + "id": download.hash, + "status": FileDownloadNotificationStatus.started + ]) } @available(iOS 14.5, *) - func downloadDidFinish(_ download: WKDownload) { + public func downloadDidFinish(_ download: WKDownload) { CAPLog.print("⚡️ Download finished") + // notify + NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ + "id": download.hash, + "status": FileDownloadNotificationStatus.completed + ]) + } + @available(iOS 14.5, *) + public func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + CAPLog.print("⚡️ Download failed") + CAPLog.print("⚡️ Error: " + error.localizedDescription) + // notify + NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ + "id": download.hash, + "error": error.localizedDescription, + "status": FileDownloadNotificationStatus.failed + ]) } // MARK: - UIScrollViewDelegate @@ -378,12 +397,22 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat CAPLog.print("\n⚡️ See above for help with debugging blank-screen issues") } - private func getUniqueFileURL(_ suggestedFilename: String, optionalSuffix: String?) -> URL { + private func getUniqueDownloadFileURL(_ suggestedFilename: String, optionalSuffix: String?) -> URL { let documentsFolderURL = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - let documentURL = documentsFolderURL.appendingPathComponent(suggestedFilename + (optionalSuffix ?? ""), isDirectory: false) - if FileManager.default.fileExists(atPath: documentURL.absoluteString) { + // + var fileComps = suggestedFilename.split(separator: "."); + var fileName = ""; + if (fileComps.count > 1) { + let fileExtension = "." + String(fileComps.popLast() ?? "") + fileName = fileComps.joined(separator: ".") + (optionalSuffix ?? "") + fileExtension + } else { + fileName = suggestedFilename + (optionalSuffix ?? "") + } + //Check if file with generated name exists + let documentURL = documentsFolderURL.appendingPathComponent(fileName, isDirectory: false) + if fileName == "" || FileManager.default.fileExists(atPath: documentURL.path) { let randSuffix = String((0..<35).map{ _ in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()! }) - return self.getUniqueFileURL(suggestedFilename, optionalSuffix: randSuffix) + return self.getUniqueDownloadFileURL(suggestedFilename, optionalSuffix: randSuffix) } return documentURL } From a2de6210b1d516abfefb98a8d38f9196f4b3807f Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Fri, 4 Mar 2022 18:07:29 -0800 Subject: [PATCH 03/14] Android code working with encoding and mime type issues (also no webhook initiated downloads for now (stream)) --- .../main/java/com/getcapacitor/Bridge.java | 4 + .../com/getcapacitor/DownloadJSInterface.java | 158 ++++++++++++++++++ .../com/getcapacitor/DownloadJSProxy.java | 47 ++++++ 3 files changed, 209 insertions(+) create mode 100644 android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java create mode 100644 android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java diff --git a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java index 3b135910c..3e14b6ea2 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java +++ b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java @@ -585,6 +585,10 @@ private void initWebView() { settings.setGeolocationEnabled(true); settings.setMediaPlaybackRequiresUserGesture(false); settings.setJavaScriptCanOpenWindowsAutomatically(true); + DownloadJSInterface downloadInterface = new DownloadJSInterface(getContext(), getActivity()); + webView.addJavascriptInterface(downloadInterface, "CapacitorDownloadInterface"); + webView.setDownloadListener(new DownloadJSProxy(this)); + if (this.config.isMixedContentAllowed()) { settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); } diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java new file mode 100644 index 000000000..8cd6d6408 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java @@ -0,0 +1,158 @@ +package com.getcapacitor; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Environment; +import android.text.TextUtils; +import android.webkit.JavascriptInterface; +import android.webkit.URLUtil; +import androidx.appcompat.app.AppCompatActivity; +import java.util.UUID; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.util.ArrayList; +import java.util.Arrays; + +/** + * Represents the bridge.webview exposed JS download interface + proxy interface injector. + * Every download request from webview will have their URLs + mime, content-disposition + * analyzed in order to determine if we do have a injector that supports it and return + * to the proxy in order to have that code executed exclusively for that request. + */ +public class DownloadJSInterface { + private Context context; + private AppCompatActivity activity; + public DownloadJSInterface(Context context, AppCompatActivity activity) { + this.context = context; + this.activity = activity; + } + + @JavascriptInterface + public void receiveStreamChunkFromJavascript(String chunk, String nativeFileURL) { + //Runtime External storage permission for saving download files + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + if (this.activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) { + Logger.debug("permission", "permission denied to WRITE_EXTERNAL_STORAGE - requesting it"); + String[] permissions = {Manifest.permission.WRITE_EXTERNAL_STORAGE}; + this.activity.requestPermissions(permissions, 1); + } + } + // + try { + FileOutputStream fOut = new FileOutputStream(getDownloadFilePath(nativeFileURL) + ".mp4", true); + OutputStreamWriter osw = new OutputStreamWriter(fOut, "UTF-8"); + osw.write(chunk); + osw.flush(); + osw.close(); + } catch (IOException e) { + Logger.error("Exception while appending to download file:", e); + } + Logger.debug("Received stream", chunk); + Logger.debug("Received stream2", nativeFileURL); + } + + /* Proxy injector + * This code analyze incoming download requests and return appropriated JS injectors. + * Injectors, handle the download request at the browser context and call the JSInterface + * with chunks of data to be written on the disk. This technic is specially useful for + * blobs and webworker initiated downloads. + */ + public static String getJavascriptBridgeForURL(String fileURL, String contentDisposition, String mimeType) { + if (fileURL.startsWith("http://") || fileURL.startsWith("https://") || fileURL.startsWith("blob:")) { + String fileName = getUniqueDownloadFileURL(fileURL, contentDisposition, mimeType, null); + return getJavascriptBridgeForReadyAvailableData(fileURL, mimeType, fileName); + // if (mimeType != null && mimeType.indexOf("application/octet-stream") != -1) { + // Logger.debug(getJavascriptBridgeForStreamData(fileURL)); + // return getJavascriptBridgeForReadyAvailableData(fileURL, mimeType, fileName); + // } else { //might already have ready-available data + + // } + + } + return null; + } + /* Injectors */ + // private static String getJavascriptBridgeForStreamData(String streamURL) { + // return "javascript: " + + // " " + + // "fetch('" + streamURL + "', { method: 'GET' }).then((res) => {\n" + + // " console.log(res.status);\n" + + // " res.text().then((text) => { console.log(text) })\n" + + // " });"; + // } + private static String getJavascriptBridgeForReadyAvailableData(String blobUrl, String mimeType, String nativeFileURL) { + return "javascript: " + + "" + + "function parseFile(file, chunkReadCallback, errorCallback, successCallback) {\n" + + " let fileSize = file.size;" + + " let chunkSize = 64 * 1024;" + + " let offset = 0;" + + " let self = this;" + + " let readBlock = null;" + + " let onLoadHandler = function(evt) {" + + " if (evt.target.error == null) {" + + " offset += evt.target.result.byteLength;" + + " chunkReadCallback((new TextDecoder('utf-8')).decode(evt.target.result));" + + " } else {" + + " errorCallback(evt.target.error);" + + " return;" + + " }" + + " if (offset >= fileSize) {" + + " if (successCallback) successCallback();" + + " return;" + + " }" + + " readBlock(offset, chunkSize, file);" + + " };" + + " readBlock = function(_offset, length, _file) {" + + " var r = new FileReader();" + + " var blob = _file.slice(_offset, length + _offset);" + + " r.onload = onLoadHandler;" + + " r.readAsArrayBuffer(blob);" + + " };" + + " readBlock(offset, chunkSize, file);" + + "};\n" + + "(() => { let xhr = new XMLHttpRequest();" + + "xhr.open('GET', '"+ blobUrl +"', true);" + + ((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Content-type','" + mimeType + "');" : "") + + "xhr.responseType = 'blob';console.log('fetchingg');" + + "xhr.onerror = xhr.onload = function(e) {console.log('fetched', this.status);" + + " if (this.status == 200) {" + + " var blob = this.response;" + + " parseFile(blob, " + + " function(chunk) { CapacitorDownloadInterface.receiveStreamChunkFromJavascript(chunk, '" + nativeFileURL + "'); }," + + " function(err) { console.error(err); }, " + + " function() { console.log('capacitor bridge, drained!'); } " + + " );" + + " } else {" + + " console.error('[Capacitor XHR] - error:', this.status, (e ? e.loaded : this.responseText));" + + " }" + + "};" + + "xhr.send();})()"; + } + /* Utils */ + public static String getDownloadFilePath(String fileName) { + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() + '/' + fileName; + } + private static String getUniqueDownloadFileURL(String fileDownloadURL, String optionalCD, String optionalMimeType, String optionalSuffix) { + String suggestedFilename = URLUtil.guessFileName(fileDownloadURL, optionalCD, optionalMimeType); + ArrayList fileComps = new ArrayList(Arrays.asList(suggestedFilename.split("."))); + String fileName = ""; + // + if (fileComps.size() > 1) { + String fileExtension = "." + fileComps.remove(fileComps.size() - 1); + fileName = TextUtils.join(".", fileComps) + (optionalSuffix != null ? optionalSuffix : "") + fileExtension; + } else { + fileName = suggestedFilename + (optionalSuffix != null ? optionalSuffix : ""); + } + //Check if file with generated name exists + File file = new File(getDownloadFilePath(fileName)); + if (file.exists()) { + String randString = UUID.randomUUID().toString(); + return getUniqueDownloadFileURL(fileDownloadURL, optionalCD, optionalMimeType, randString); + } + return fileName; + } +} diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java new file mode 100644 index 000000000..bc7e83c52 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java @@ -0,0 +1,47 @@ +package com.getcapacitor; + +import android.webkit.ServiceWorkerClient; +import android.webkit.ServiceWorkerController; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; + +/** + * Represents the bridge.webview download proxy to jsInterface (DownloadJSInterface class). + * Every download request from webview will be sent to the proxy, which decides to inject + * dynamic javascript upon the 'protocol' interface aviability. + */ +public class DownloadJSProxy implements android.webkit.DownloadListener { + private Bridge bridge; + public DownloadJSProxy(Bridge bridge) { + this.bridge = bridge; + this.installServiceWorkerProxy(); + } + @Override + public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimeType, long contentLength) { + //Debug + Logger.debug("Capacitor webview download start request", url); + Logger.debug(userAgent + " - " + contentDisposition + " - " + mimeType); + //Check if we can handle the URL.. + String bridge = DownloadJSInterface.getJavascriptBridgeForURL(url, contentDisposition, mimeType); + if (bridge != null) { + this.bridge.getWebView().loadUrl(bridge); + } else { + Logger.info("Capacitor webview download has no handler for the following url", url); + } + } + + // + private void installServiceWorkerProxy() { + //Downloads can be done via webworker, webworkers might need local resources, we enable that + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + ServiceWorkerController swController = ServiceWorkerController.getInstance(); + swController.setServiceWorkerClient(new ServiceWorkerClient() { + @Override + public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) { + Logger.debug("ServiceWorker Request", request.getUrl().toString()); + return bridge.getLocalServer().shouldInterceptRequest(request); + } + }); + } + } +} \ No newline at end of file From f353ddf1549ada0b86882a3fe4bc6b1055ae5925 Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Thu, 10 Mar 2022 18:04:28 -0800 Subject: [PATCH 04/14] More changes relative to android download, implement activity with file picker and duplex stream for javascript proxy write --- .../main/java/com/getcapacitor/Bridge.java | 6 +- .../com/getcapacitor/DownloadJSActivity.java | 246 ++++++++++++++++++ .../com/getcapacitor/DownloadJSInterface.java | 140 +++++----- .../com/getcapacitor/DownloadJSProxy.java | 8 +- 4 files changed, 327 insertions(+), 73 deletions(-) create mode 100644 android/capacitor/src/main/java/com/getcapacitor/DownloadJSActivity.java diff --git a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java index 3e14b6ea2..1e7aa7052 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java +++ b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java @@ -585,9 +585,9 @@ private void initWebView() { settings.setGeolocationEnabled(true); settings.setMediaPlaybackRequiresUserGesture(false); settings.setJavaScriptCanOpenWindowsAutomatically(true); - DownloadJSInterface downloadInterface = new DownloadJSInterface(getContext(), getActivity()); - webView.addJavascriptInterface(downloadInterface, "CapacitorDownloadInterface"); - webView.setDownloadListener(new DownloadJSProxy(this)); + DownloadJSProxy downloadProxy = new DownloadJSProxy(this); + webView.addJavascriptInterface(downloadProxy.jsInterface(), downloadProxy.jsInterfaceName()); + webView.setDownloadListener(downloadProxy); if (this.config.isMixedContentAllowed()) { settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSActivity.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSActivity.java new file mode 100644 index 000000000..342fa2fba --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSActivity.java @@ -0,0 +1,246 @@ +package com.getcapacitor; + +import android.content.Context; +import android.content.Intent; +import android.app.Activity; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.text.TextUtils; +import android.webkit.URLUtil; + +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.contract.ActivityResultContract; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * An {@link ActivityResultContract} to {@link Activity#requestPermissions request a permission} + */ +public class DownloadJSActivity extends ActivityResultContract { + + public static class Input { + public String fileNameURL; + public String optionalMimeType; + public String contentDisposition; + public String operationID; + public Input(String operationID, String fileNameURL, String optionalMimeType, String contentDisposition) { + this.operationID = operationID; + this.fileNameURL = fileNameURL; + this.optionalMimeType = optionalMimeType; + this.contentDisposition = contentDisposition; + } + } + public class OperationStatus { + public Input input; + public String fullDestinationPath; + public String destinationFileName; + public PipedOutputStream outStream; + public PipedInputStream inStream; + //state + public Boolean closed; + public Boolean started; + public Boolean pendingClose; + public Boolean failureClose; + // + public OperationStatus(Input input, String[] paths) { + this.input = input; + this.fullDestinationPath = paths[0]; + this.destinationFileName = paths[1]; + this.closed = this.started = this.pendingClose = this.failureClose = false; + this.outStream = new PipedOutputStream(); + try { + this.inStream = new PipedInputStream(); + this.inStream.connect(this.outStream); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + private static String EXTRA_OPERATION_ID = "OPERATION_ID"; + + private AppCompatActivity activity; + private HashMap operations; + private OperationStatus pendingOperation; + + + public DownloadJSActivity(AppCompatActivity activity) { + this.activity = activity; + this.operations = new HashMap(); + } + + /* public */ + public void appendToOperation(String operationID, String data) { + //get operation status + Logger.debug("A", operationID); + OperationStatus operationStatus = this.operations.get(operationID); + if (operationStatus == null && this.pendingOperation.input.operationID.equals(operationID)) operationStatus = this.pendingOperation; + if (operationStatus == null || operationStatus.closed) return; //already closed? + Logger.debug("BV"); + //write + try { + operationStatus.outStream.write(data.getBytes()); + } catch (IOException e) { + Logger.debug(e.toString()); + } + Logger.debug("Wrote!"); + } + public void failOperation(String operationID) { + //get operation status + OperationStatus operationStatus = this.operations.get(operationID); + if (operationStatus == null && this.pendingOperation.input.operationID.equals(operationID)) operationStatus = this.pendingOperation; + if (operationStatus == null || operationStatus.closed) return; //already closed? + //Ask for close + operationStatus.failureClose = true; + operationStatus.pendingClose = true; + } + public void completeOperation(String operationID) { + //get operation status + OperationStatus operationStatus = this.operations.get(operationID); + if (operationStatus == null && this.pendingOperation.input.operationID.equals(operationID)) operationStatus = this.pendingOperation; + if (operationStatus == null || operationStatus.closed) return; //already closed? + //Ask for close + operationStatus.pendingClose = true; + } + + + /* ActivityResultContract Implementation */ + public Intent createIntent(@NonNull Context context, DownloadJSActivity.Input input) { + //ask path + String[] paths = this.getUniqueDownloadFileNameFromDetails(input.fileNameURL, input.optionalMimeType, input.contentDisposition, null); + if (paths == null) return null; + //Create/config intent to prompt for file selection + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.putExtra(Intent.EXTRA_TITLE, paths[1]); + intent.putExtra(EXTRA_OPERATION_ID, input.operationID); + + if (input.optionalMimeType != null) intent.setType(input.optionalMimeType); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, paths[0]); + //Add operation + this.pendingOperation = new OperationStatus(input, paths); + // + return intent; + } + public Boolean parseResult(int resultCode, @Nullable Intent result) { + //get operation status + OperationStatus operationStatus = this.pendingOperation; + if (operationStatus == null) return false; //double call? + //Reset pointer + this.pendingOperation = null; + // + Logger.debug("Code", String.valueOf(resultCode)); + if (resultCode == Activity.RESULT_OK) { + this.operations.put(operationStatus.input.operationID, operationStatus); + this.createPipeForOperation(operationStatus, result.getData()); + return true; + } else if (resultCode == Activity.RESULT_CANCELED){ + //todo: close all + return false; + } return false; + } + + // + private void createPipeForOperation(OperationStatus operationStatus, Uri uri) { + //check for operation finished + if (operationStatus.started || operationStatus.closed) return; + // + operationStatus.started = true; + // + try { + OutputStream output = this.activity.getContentResolver().openOutputStream(uri); + int offset = 0; + while (!operationStatus.closed) { + //Have what to read? + int toRead = Math.min(operationStatus.inStream.available(), 64 * 1024); + Logger.debug("To read", String.valueOf(toRead)); + if (toRead <= 0) { + if (operationStatus.pendingClose) operationStatus.closed = true; + continue; + } + // + byte[] bytes = new byte[toRead]; + operationStatus.inStream.read(bytes, offset, toRead); + output.write(bytes); + offset += toRead - 1; + } + output.flush(); + output.close(); + Logger.debug("done pipe"); + // Tell the media scanner about the new file so that it is + // immediately available to the user. + MediaScannerConnection.scanFile(this.activity, + new String[] { uri.toString() }, null, + new MediaScannerConnection.OnScanCompletedListener() { + public void onScanCompleted(String path, Uri uri2) { + Logger.debug("ExternalStorage", "Scanned " + path + ":"); + Logger.debug("ExternalStorage", "-> uri=" + uri2); + } + }); + + } catch (IOException e) { + Logger.debug("onActivityResult: ", e.toString()); + } + } + + + /* FS Utils */ + private String getDownloadFilePath(String fileName) { + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() + '/' + fileName; + } + private Boolean checkCreateDefaultDir() { + Boolean created = false; + try { + File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + if (!dir.exists()) dir.mkdir(); + created = true; + } catch (RuntimeException e) { + Logger.debug("Error while creating default download dir:", e.toString()); + } + return created; + } + private String[] getUniqueDownloadFileNameFromDetails(String fileDownloadURL, String optionalCD, String optionalMimeType, @Nullable Integer optionalSuffix) { + //Auxs for filename gen. + String suggestedFilename = URLUtil.guessFileName(fileDownloadURL, optionalCD, optionalMimeType); + ArrayList fileComps = new ArrayList(Arrays.asList(suggestedFilename.split("."))); + String suffix = (optionalSuffix != null ? " (" + String.valueOf(optionalSuffix) + ")" : ""); + //Check for invalid filename + if (suggestedFilename == null || suggestedFilename.length() <= 0) suggestedFilename = UUID.randomUUID().toString(); + //Generate filename + String fileName = ""; + if (fileComps.size() > 1) { + String fileExtension = "." + fileComps.remove(fileComps.size() - 1); + fileName = TextUtils.join(".", fileComps) + suffix + fileExtension; + } else { + fileName = suggestedFilename + suffix; + } + //Check for default dir (might not exists per official documentation) + if (!this.checkCreateDefaultDir()) return null; + //Check if file with generated name exists + String fullPath = this.getDownloadFilePath(fileName); + //File picker should do this for us +// File file = new File(fullPath); +// if (file.exists()) { +// Integer nextSuffix = (optionalSuffix != null ? optionalSuffix + 1 : 1); +// return this.getUniqueDownloadFileNameFromDetails(fileDownloadURL, optionalCD, optionalMimeType, nextSuffix); +// } + return new String[]{fullPath, fileName}; + } +} diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java index 8cd6d6408..0395ba400 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java @@ -2,11 +2,17 @@ import android.Manifest; import android.content.Context; +import android.content.Intent; import android.content.pm.PackageManager; +import android.os.Build; import android.os.Environment; +import android.provider.DocumentsContract; import android.text.TextUtils; import android.webkit.JavascriptInterface; import android.webkit.URLUtil; + +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; import androidx.appcompat.app.AppCompatActivity; import java.util.UUID; import java.io.File; @@ -23,35 +29,52 @@ * to the proxy in order to have that code executed exclusively for that request. */ public class DownloadJSInterface { - private Context context; - private AppCompatActivity activity; - public DownloadJSInterface(Context context, AppCompatActivity activity) { - this.context = context; - this.activity = activity; + private DownloadJSActivity downloadActivity; + private ActivityResultLauncher launcher; + public DownloadJSInterface(AppCompatActivity activity) { + this.downloadActivity = new DownloadJSActivity(activity); + this.launcher = activity.registerForActivityResult(this.downloadActivity, + new ActivityResultCallback() { + @Override + public void onActivityResult(Boolean result) { + Logger.debug("Activity result ->>>>", String.valueOf(result)); + } + }); } @JavascriptInterface - public void receiveStreamChunkFromJavascript(String chunk, String nativeFileURL) { - //Runtime External storage permission for saving download files - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { - if (this.activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) { - Logger.debug("permission", "permission denied to WRITE_EXTERNAL_STORAGE - requesting it"); - String[] permissions = {Manifest.permission.WRITE_EXTERNAL_STORAGE}; - this.activity.requestPermissions(permissions, 1); - } - } - // - try { - FileOutputStream fOut = new FileOutputStream(getDownloadFilePath(nativeFileURL) + ".mp4", true); - OutputStreamWriter osw = new OutputStreamWriter(fOut, "UTF-8"); - osw.write(chunk); - osw.flush(); - osw.close(); - } catch (IOException e) { - Logger.error("Exception while appending to download file:", e); - } + public void receiveStreamChunkFromJavascript(String chunk, String operationID) { +// //Runtime External storage permission for saving download files +// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { +// if (this.activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) { +// Logger.debug("permission", "permission denied to WRITE_EXTERNAL_STORAGE - requesting it"); +// String[] permissions = {Manifest.permission.WRITE_EXTERNAL_STORAGE}; +// this.activity.requestPermissions(permissions, 1); +// } +// } +// // +// try { +// FileOutputStream fOut = new FileOutputStream(getDownloadFilePath(nativeFileURL) + ".mp4", true); +// OutputStreamWriter osw = new OutputStreamWriter(fOut, "UTF-8"); +// osw.write(chunk); +// osw.flush(); +// osw.close(); +// } catch (IOException e) { +// Logger.error("Exception while appending to download file:", e); +// } Logger.debug("Received stream", chunk); - Logger.debug("Received stream2", nativeFileURL); + Logger.debug("Received stream2", operationID); + this.downloadActivity.appendToOperation(operationID, chunk); + } + @JavascriptInterface + public void receiveStreamErrorFromJavascript(String error, String operationID) { + Logger.debug("Received error", error + " - " + operationID); + this.downloadActivity.failOperation(operationID); + } + @JavascriptInterface + public void receiveStreamCompletionFromJavascript(String operationID) { + Logger.debug("Operation completed", operationID); + this.downloadActivity.completeOperation(operationID); } /* Proxy injector @@ -60,30 +83,32 @@ public void receiveStreamChunkFromJavascript(String chunk, String nativeFileURL) * with chunks of data to be written on the disk. This technic is specially useful for * blobs and webworker initiated downloads. */ - public static String getJavascriptBridgeForURL(String fileURL, String contentDisposition, String mimeType) { + public String getJavascriptBridgeForURL(String fileURL, String contentDisposition, String mimeType) { if (fileURL.startsWith("http://") || fileURL.startsWith("https://") || fileURL.startsWith("blob:")) { - String fileName = getUniqueDownloadFileURL(fileURL, contentDisposition, mimeType, null); - return getJavascriptBridgeForReadyAvailableData(fileURL, mimeType, fileName); - // if (mimeType != null && mimeType.indexOf("application/octet-stream") != -1) { - // Logger.debug(getJavascriptBridgeForStreamData(fileURL)); - // return getJavascriptBridgeForReadyAvailableData(fileURL, mimeType, fileName); - // } else { //might already have ready-available data - - // } + // + String operationID = UUID.randomUUID().toString(); + DownloadJSActivity.Input input = new DownloadJSActivity.Input(operationID, fileURL, mimeType, contentDisposition); + this.launcher.launch(input); + // + if (mimeType != null && mimeType.indexOf("application/octet-stream") != -1) { + return this.getJavascriptBridgeForReadyAvailableData(fileURL, mimeType, operationID); + } else { //might already have ready-available data + return this.getJavascriptBridgeForReadyAvailableData(fileURL, mimeType, operationID); + } } return null; } /* Injectors */ - // private static String getJavascriptBridgeForStreamData(String streamURL) { - // return "javascript: " + - // " " + - // "fetch('" + streamURL + "', { method: 'GET' }).then((res) => {\n" + - // " console.log(res.status);\n" + - // " res.text().then((text) => { console.log(text) })\n" + - // " });"; - // } - private static String getJavascriptBridgeForReadyAvailableData(String blobUrl, String mimeType, String nativeFileURL) { + private String getJavascriptBridgeForStreamData(String streamURL) { + return "javascript: " + + " " + + "fetch('" + streamURL + "', { method: 'GET' }).then((res) => {\n" + + " console.log(res.status);\n" + + " res.text().then((text) => { console.log(text) })\n" + + " });"; + } + private String getJavascriptBridgeForReadyAvailableData(String blobUrl, String mimeType, String operationID) { return "javascript: " + "" + "function parseFile(file, chunkReadCallback, errorCallback, successCallback) {\n" + @@ -122,9 +147,9 @@ private static String getJavascriptBridgeForReadyAvailableData(String blobUrl, S " if (this.status == 200) {" + " var blob = this.response;" + " parseFile(blob, " + - " function(chunk) { CapacitorDownloadInterface.receiveStreamChunkFromJavascript(chunk, '" + nativeFileURL + "'); }," + - " function(err) { console.error(err); }, " + - " function() { console.log('capacitor bridge, drained!'); } " + + " function(chunk) { CapacitorDownloadInterface.receiveStreamChunkFromJavascript(chunk, '" + operationID + "'); }," + + " function(err) { console.error(err); CapacitorDownloadInterface.receiveStreamChunkFromJavascript(err.message, '" + operationID + "'); }, " + + " function() { console.log('capacitor bridge, drained!'); CapacitorDownloadInterface.receiveStreamCompletionFromJavascript('" + operationID + "'); } " + " );" + " } else {" + " console.error('[Capacitor XHR] - error:', this.status, (e ? e.loaded : this.responseText));" + @@ -132,27 +157,4 @@ private static String getJavascriptBridgeForReadyAvailableData(String blobUrl, S "};" + "xhr.send();})()"; } - /* Utils */ - public static String getDownloadFilePath(String fileName) { - return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() + '/' + fileName; - } - private static String getUniqueDownloadFileURL(String fileDownloadURL, String optionalCD, String optionalMimeType, String optionalSuffix) { - String suggestedFilename = URLUtil.guessFileName(fileDownloadURL, optionalCD, optionalMimeType); - ArrayList fileComps = new ArrayList(Arrays.asList(suggestedFilename.split("."))); - String fileName = ""; - // - if (fileComps.size() > 1) { - String fileExtension = "." + fileComps.remove(fileComps.size() - 1); - fileName = TextUtils.join(".", fileComps) + (optionalSuffix != null ? optionalSuffix : "") + fileExtension; - } else { - fileName = suggestedFilename + (optionalSuffix != null ? optionalSuffix : ""); - } - //Check if file with generated name exists - File file = new File(getDownloadFilePath(fileName)); - if (file.exists()) { - String randString = UUID.randomUUID().toString(); - return getUniqueDownloadFileURL(fileDownloadURL, optionalCD, optionalMimeType, randString); - } - return fileName; - } } diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java index bc7e83c52..6da9f5de2 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java @@ -12,17 +12,23 @@ */ public class DownloadJSProxy implements android.webkit.DownloadListener { private Bridge bridge; + private DownloadJSInterface downloadInterface; public DownloadJSProxy(Bridge bridge) { this.bridge = bridge; + this.downloadInterface = new DownloadJSInterface(this.bridge.getActivity()); this.installServiceWorkerProxy(); } + + // + public DownloadJSInterface jsInterface() { return this.downloadInterface; } + public String jsInterfaceName() { return "CapacitorDownloadInterface"; } @Override public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimeType, long contentLength) { //Debug Logger.debug("Capacitor webview download start request", url); Logger.debug(userAgent + " - " + contentDisposition + " - " + mimeType); //Check if we can handle the URL.. - String bridge = DownloadJSInterface.getJavascriptBridgeForURL(url, contentDisposition, mimeType); + String bridge = this.downloadInterface.getJavascriptBridgeForURL(url, contentDisposition, mimeType); if (bridge != null) { this.bridge.getWebView().loadUrl(bridge); } else { From 79cd81ea1deafbf942d2d9fd7f0b99a35e6b43b7 Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Fri, 11 Mar 2022 11:53:03 -0800 Subject: [PATCH 05/14] Working thread operation with content type resolution from XHR --- .../com/getcapacitor/DownloadJSActivity.java | 254 ++++++++++-------- ...ava => DownloadJSOperationController.java} | 115 ++++---- .../com/getcapacitor/DownloadJSProxy.java | 4 +- 3 files changed, 208 insertions(+), 165 deletions(-) rename android/capacitor/src/main/java/com/getcapacitor/{DownloadJSInterface.java => DownloadJSOperationController.java} (55%) diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSActivity.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSActivity.java index 342fa2fba..5ed854384 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSActivity.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSActivity.java @@ -6,15 +6,12 @@ import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; -import android.os.Bundle; import android.os.Environment; import android.provider.DocumentsContract; import android.text.TextUtils; import android.webkit.URLUtil; -import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.contract.ActivityResultContract; -import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; @@ -24,17 +21,15 @@ import java.io.OutputStream; import java.io.PipedInputStream; import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; -import java.util.Map; import java.util.UUID; +import java.util.concurrent.Executors; -/** - * An {@link ActivityResultContract} to {@link Activity#requestPermissions request a permission} - */ -public class DownloadJSActivity extends ActivityResultContract { - +public class DownloadJSOperationController extends ActivityResultContract { + /* DownloadJSActivity Input */ public static class Input { public String fileNameURL; public String optionalMimeType; @@ -47,10 +42,10 @@ public Input(String operationID, String fileNameURL, String optionalMimeType, St this.contentDisposition = contentDisposition; } } - public class OperationStatus { - public Input input; - public String fullDestinationPath; - public String destinationFileName; + /* DownloadJSActivity internal operation */ + public static class Operation { + final private Input input; + public String operationID; public PipedOutputStream outStream; public PipedInputStream inStream; //state @@ -59,158 +54,203 @@ public class OperationStatus { public Boolean pendingClose; public Boolean failureClose; // - public OperationStatus(Input input, String[] paths) { + public Operation(Input input) { this.input = input; - this.fullDestinationPath = paths[0]; - this.destinationFileName = paths[1]; + this.operationID = input.operationID; this.closed = this.started = this.pendingClose = this.failureClose = false; this.outStream = new PipedOutputStream(); try { - this.inStream = new PipedInputStream(); + this.inStream = new PipedInputStream(1024 * 64); this.inStream.connect(this.outStream); } catch (IOException e) { - e.printStackTrace(); + this.failureClose = true; + this.pendingClose = true; + Logger.debug("Exception while opening/connecting DownloadJSActivity streams.", e.toString()); } } } - private static String EXTRA_OPERATION_ID = "OPERATION_ID"; - - private AppCompatActivity activity; - private HashMap operations; - private OperationStatus pendingOperation; - - - public DownloadJSActivity(AppCompatActivity activity) { + /* DownloadJSActivity */ + final private static String EXTRA_OPERATION_ID = "OPERATION_ID"; + final private AppCompatActivity activity; + final private HashMap operations; + private Operation pendingOperation; + // + public DownloadJSOperationController(AppCompatActivity activity) { this.activity = activity; - this.operations = new HashMap(); + this.operations = new HashMap<>(); } - /* public */ + /* Public operations */ public void appendToOperation(String operationID, String data) { //get operation status - Logger.debug("A", operationID); - OperationStatus operationStatus = this.operations.get(operationID); - if (operationStatus == null && this.pendingOperation.input.operationID.equals(operationID)) operationStatus = this.pendingOperation; - if (operationStatus == null || operationStatus.closed) return; //already closed? - Logger.debug("BV"); + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null || operation.closed) return; //already closed? //write try { - operationStatus.outStream.write(data.getBytes()); + operation.outStream.write(data.getBytes(StandardCharsets.ISO_8859_1)); } catch (IOException e) { - Logger.debug(e.toString()); + Logger.debug("Exception while writting on DownloadJSActivity stream. Closing it!", e.toString()); + //Ask for close + operation.pendingClose = true; } - Logger.debug("Wrote!"); } public void failOperation(String operationID) { //get operation status - OperationStatus operationStatus = this.operations.get(operationID); - if (operationStatus == null && this.pendingOperation.input.operationID.equals(operationID)) operationStatus = this.pendingOperation; - if (operationStatus == null || operationStatus.closed) return; //already closed? + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null || operation.closed) return; //already closed? //Ask for close - operationStatus.failureClose = true; - operationStatus.pendingClose = true; + operation.failureClose = true; + operation.pendingClose = true; } public void completeOperation(String operationID) { //get operation status - OperationStatus operationStatus = this.operations.get(operationID); - if (operationStatus == null && this.pendingOperation.input.operationID.equals(operationID)) operationStatus = this.pendingOperation; - if (operationStatus == null || operationStatus.closed) return; //already closed? + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null || operation.closed) return; //already closed? //Ask for close - operationStatus.pendingClose = true; + operation.pendingClose = true; } /* ActivityResultContract Implementation */ - public Intent createIntent(@NonNull Context context, DownloadJSActivity.Input input) { + @NonNull + public Intent createIntent(@NonNull Context context, DownloadJSOperationController.Input input) { //ask path String[] paths = this.getUniqueDownloadFileNameFromDetails(input.fileNameURL, input.optionalMimeType, input.contentDisposition, null); - if (paths == null) return null; //Create/config intent to prompt for file selection Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.putExtra(Intent.EXTRA_TITLE, paths[1]); + if (paths != null && paths[1] != null) intent.putExtra(Intent.EXTRA_TITLE, paths[1]); intent.putExtra(EXTRA_OPERATION_ID, input.operationID); - if (input.optionalMimeType != null) intent.setType(input.optionalMimeType); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, paths[0]); + if (paths != null && paths[0] != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, paths[0]); //Add operation - this.pendingOperation = new OperationStatus(input, paths); + this.pendingOperation = new Operation(input); // return intent; } public Boolean parseResult(int resultCode, @Nullable Intent result) { //get operation status - OperationStatus operationStatus = this.pendingOperation; - if (operationStatus == null) return false; //double call? - //Reset pointer - this.pendingOperation = null; - // - Logger.debug("Code", String.valueOf(resultCode)); - if (resultCode == Activity.RESULT_OK) { - this.operations.put(operationStatus.input.operationID, operationStatus); - this.createPipeForOperation(operationStatus, result.getData()); + Operation operation = this.pendingOperation; + if (operation == null) return false; //double call? + //Process if resultCode is OK and have result + if (resultCode == Activity.RESULT_OK && result != null) { + this.operations.put(operation.input.operationID, operation); + this.pendingOperation = null; + this.createThreadedPipeForOperation(operation, result.getData()); return true; - } else if (resultCode == Activity.RESULT_CANCELED){ - //todo: close all - return false; - } return false; + } + //Cancel pre operation (haven't started yet) + this.pendingOperation = null; //can't be used for writting anymore + this.cancelPreOperation(operation); + return false; } - // - private void createPipeForOperation(OperationStatus operationStatus, Uri uri) { + //Thread operation that uses duplex stream + private void createThreadedPipeForOperation(Operation operation, Uri uri) { + DownloadJSOperationController upperRef = this; + Executors.newSingleThreadExecutor().execute(new Runnable() { + @Override + public void run() { + upperRef.createPipeForOperation(operation, uri); + } + }); + } + private void createPipeForOperation(Operation operation, Uri uri) { //check for operation finished - if (operationStatus.started || operationStatus.closed) return; - // - operationStatus.started = true; + if (operation.started || operation.closed) return; + //start operation + operation.started = true; // try { OutputStream output = this.activity.getContentResolver().openOutputStream(uri); - int offset = 0; - while (!operationStatus.closed) { + int lastReadSize = 0; + boolean flushed = false; + while (!operation.pendingClose || lastReadSize > 0 || !flushed) { //Have what to read? - int toRead = Math.min(operationStatus.inStream.available(), 64 * 1024); - Logger.debug("To read", String.valueOf(toRead)); - if (toRead <= 0) { - if (operationStatus.pendingClose) operationStatus.closed = true; + lastReadSize = Math.min(operation.inStream.available(), 64 * 1024); + if (lastReadSize <= 0) { + //read size is 0, attempt to flush duplex and make sure we got everything + if (!flushed) { + operation.outStream.flush(); + flushed = true; + } continue; } - // - byte[] bytes = new byte[toRead]; - operationStatus.inStream.read(bytes, offset, toRead); + //Reset flushed state if we got more data + flushed = false; + //Read + byte[] bytes = new byte[lastReadSize]; + lastReadSize = operation.inStream.read(bytes, 0, lastReadSize); output.write(bytes); - offset += toRead - 1; } - output.flush(); + //Close streams + output.flush(); //IO flush output.close(); - Logger.debug("done pipe"); - // Tell the media scanner about the new file so that it is - // immediately available to the user. - MediaScannerConnection.scanFile(this.activity, - new String[] { uri.toString() }, null, - new MediaScannerConnection.OnScanCompletedListener() { - public void onScanCompleted(String path, Uri uri2) { - Logger.debug("ExternalStorage", "Scanned " + path + ":"); - Logger.debug("ExternalStorage", "-> uri=" + uri2); - } - }); - - } catch (IOException e) { - Logger.debug("onActivityResult: ", e.toString()); + operation.closed = true; + operation.outStream.close(); + operation.inStream.close(); + //Release from operations + this.releaseOperation(operation.input.operationID); + //Ask for media scan + this.performMediaScan(uri); + } catch (Exception e) { + Logger.debug("Exception while running DownloadJSActivity threaded operation.", e.toString()); + //Cancel operation stream (safely) and release from operations + this.cancelPreOperation(operation); + this.releaseOperation(operation.input.operationID); } + Logger.debug("DownloadJSActivity completed!", operation.input.operationID); } + /* Operation Utils */ + private void cancelPreOperation(Operation operation) { + operation.pendingClose = true; + operation.closed = true; + try { + operation.outStream.close(); + operation.inStream.close(); + } catch (IOException ignored) { } //failsafe stream close + } + private void releaseOperation(String operationID) { + //get operation status + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null) return; //already closed? + //Check for pending closure (loop interruption) + if (!operation.pendingClose) operation.pendingClose = true; + //Remove from operations + this.operations.remove(operation.input.operationID); + } + + /* Media utils */ + private void performMediaScan(Uri uri) { + // Tell the media scanner about the new file so that it is + // immediately available to the user. + MediaScannerConnection.scanFile(this.activity, + new String[] { uri.toString() }, null, + new MediaScannerConnection.OnScanCompletedListener() { + public void onScanCompleted(String path, Uri uri2) { + Logger.debug("ExternalStorage", "Scanned " + path + ":"); + Logger.debug("ExternalStorage", "-> uri=" + uri2); + } + }); + } /* FS Utils */ private String getDownloadFilePath(String fileName) { return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() + '/' + fileName; } - private Boolean checkCreateDefaultDir() { - Boolean created = false; + private boolean checkCreateDefaultDir() { + boolean created = false; try { File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - if (!dir.exists()) dir.mkdir(); - created = true; + if (!dir.exists()) { + if (dir.mkdir()) created = true; + } else created = true; } catch (RuntimeException e) { Logger.debug("Error while creating default download dir:", e.toString()); } @@ -219,12 +259,12 @@ private Boolean checkCreateDefaultDir() { private String[] getUniqueDownloadFileNameFromDetails(String fileDownloadURL, String optionalCD, String optionalMimeType, @Nullable Integer optionalSuffix) { //Auxs for filename gen. String suggestedFilename = URLUtil.guessFileName(fileDownloadURL, optionalCD, optionalMimeType); - ArrayList fileComps = new ArrayList(Arrays.asList(suggestedFilename.split("."))); - String suffix = (optionalSuffix != null ? " (" + String.valueOf(optionalSuffix) + ")" : ""); + ArrayList fileComps = new ArrayList<>(Arrays.asList(suggestedFilename.split("."))); + String suffix = (optionalSuffix != null ? " (" + optionalSuffix + ")" : ""); //Check for invalid filename - if (suggestedFilename == null || suggestedFilename.length() <= 0) suggestedFilename = UUID.randomUUID().toString(); + if (suggestedFilename.length() <= 0) suggestedFilename = UUID.randomUUID().toString(); //Generate filename - String fileName = ""; + String fileName; if (fileComps.size() > 1) { String fileExtension = "." + fileComps.remove(fileComps.size() - 1); fileName = TextUtils.join(".", fileComps) + suffix + fileExtension; @@ -236,11 +276,11 @@ private String[] getUniqueDownloadFileNameFromDetails(String fileDownloadURL, St //Check if file with generated name exists String fullPath = this.getDownloadFilePath(fileName); //File picker should do this for us -// File file = new File(fullPath); -// if (file.exists()) { -// Integer nextSuffix = (optionalSuffix != null ? optionalSuffix + 1 : 1); -// return this.getUniqueDownloadFileNameFromDetails(fileDownloadURL, optionalCD, optionalMimeType, nextSuffix); -// } + File file = new File(fullPath); + if (file.exists()) { + Integer nextSuffix = (optionalSuffix != null ? optionalSuffix + 1 : 1); + return this.getUniqueDownloadFileNameFromDetails(fileDownloadURL, optionalCD, optionalMimeType, nextSuffix); + } return new String[]{fullPath, fileName}; } } diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java similarity index 55% rename from android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java rename to android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java index 0395ba400..bdd3dd6c9 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java @@ -13,7 +13,10 @@ import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; + +import java.util.HashMap; import java.util.UUID; import java.io.File; import java.io.FileOutputStream; @@ -29,52 +32,45 @@ * to the proxy in order to have that code executed exclusively for that request. */ public class DownloadJSInterface { - private DownloadJSActivity downloadActivity; - private ActivityResultLauncher launcher; + final private DownloadJSOperationController operationsController; + final private ActivityResultLauncher launcher; + final private HashMap pendingInputs; + // public DownloadJSInterface(AppCompatActivity activity) { - this.downloadActivity = new DownloadJSActivity(activity); - this.launcher = activity.registerForActivityResult(this.downloadActivity, + this.operationsController = new DownloadJSOperationController(activity); + this.pendingInputs = new HashMap<>(); + this.launcher = activity.registerForActivityResult(this.operationsController, new ActivityResultCallback() { @Override public void onActivityResult(Boolean result) { - Logger.debug("Activity result ->>>>", String.valueOf(result)); + Logger.debug("DownloadJSActivity result", String.valueOf(result)); } }); } + /* JavascriptInterface imp. */ + @JavascriptInterface + public void receiveContentTypeFromJavascript(String contentType, String operationID) { + this.transitionPendingInputOperation(operationID, contentType, false); + } @JavascriptInterface public void receiveStreamChunkFromJavascript(String chunk, String operationID) { -// //Runtime External storage permission for saving download files -// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { -// if (this.activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) { -// Logger.debug("permission", "permission denied to WRITE_EXTERNAL_STORAGE - requesting it"); -// String[] permissions = {Manifest.permission.WRITE_EXTERNAL_STORAGE}; -// this.activity.requestPermissions(permissions, 1); -// } -// } -// // -// try { -// FileOutputStream fOut = new FileOutputStream(getDownloadFilePath(nativeFileURL) + ".mp4", true); -// OutputStreamWriter osw = new OutputStreamWriter(fOut, "UTF-8"); -// osw.write(chunk); -// osw.flush(); -// osw.close(); -// } catch (IOException e) { -// Logger.error("Exception while appending to download file:", e); -// } - Logger.debug("Received stream", chunk); - Logger.debug("Received stream2", operationID); - this.downloadActivity.appendToOperation(operationID, chunk); +// Logger.debug("Received stream", chunk); +// Logger.debug("Received stream2", operationID); + this.transitionPendingInputOperation(operationID, null, null); + //Check if activity has started already + this.operationsController.appendToOperation(operationID, chunk); } @JavascriptInterface public void receiveStreamErrorFromJavascript(String error, String operationID) { - Logger.debug("Received error", error + " - " + operationID); - this.downloadActivity.failOperation(operationID); +// Logger.debug("Received error", error + " - " + operationID); + this.transitionPendingInputOperation(operationID, null, true); + this.operationsController.failOperation(operationID); } @JavascriptInterface public void receiveStreamCompletionFromJavascript(String operationID) { - Logger.debug("Operation completed", operationID); - this.downloadActivity.completeOperation(operationID); +// Logger.debug("Operation completed", operationID); + this.operationsController.completeOperation(operationID); } /* Proxy injector @@ -85,30 +81,19 @@ public void receiveStreamCompletionFromJavascript(String operationID) { */ public String getJavascriptBridgeForURL(String fileURL, String contentDisposition, String mimeType) { if (fileURL.startsWith("http://") || fileURL.startsWith("https://") || fileURL.startsWith("blob:")) { - // + //setup background operation input (not started yet) + //will wait either stream start on content-type resolution to start asking + //for file pick and stream drain String operationID = UUID.randomUUID().toString(); - DownloadJSActivity.Input input = new DownloadJSActivity.Input(operationID, fileURL, mimeType, contentDisposition); - this.launcher.launch(input); - // - if (mimeType != null && mimeType.indexOf("application/octet-stream") != -1) { - return this.getJavascriptBridgeForReadyAvailableData(fileURL, mimeType, operationID); - } else { //might already have ready-available data - return this.getJavascriptBridgeForReadyAvailableData(fileURL, mimeType, operationID); - } - + DownloadJSOperationController.Input input = new DownloadJSOperationController.Input(operationID, fileURL, mimeType, contentDisposition); + this.pendingInputs.put(operationID, input); + //Return JS bridge with operationID tagged + return this.getJavascriptInterfaceBridgeForReadyAvailableData(fileURL, mimeType, operationID); } return null; } /* Injectors */ - private String getJavascriptBridgeForStreamData(String streamURL) { - return "javascript: " + - " " + - "fetch('" + streamURL + "', { method: 'GET' }).then((res) => {\n" + - " console.log(res.status);\n" + - " res.text().then((text) => { console.log(text) })\n" + - " });"; - } - private String getJavascriptBridgeForReadyAvailableData(String blobUrl, String mimeType, String operationID) { + private String getJavascriptInterfaceBridgeForReadyAvailableData(String blobUrl, String mimeType, String operationID) { return "javascript: " + "" + "function parseFile(file, chunkReadCallback, errorCallback, successCallback) {\n" + @@ -119,8 +104,8 @@ private String getJavascriptBridgeForReadyAvailableData(String blobUrl, String m " let readBlock = null;" + " let onLoadHandler = function(evt) {" + " if (evt.target.error == null) {" + - " offset += evt.target.result.byteLength;" + - " chunkReadCallback((new TextDecoder('utf-8')).decode(evt.target.result));" + + " offset += evt.target.result.length;" + + " chunkReadCallback(evt.target.result);" + " } else {" + " errorCallback(evt.target.error);" + " return;" + @@ -135,21 +120,23 @@ private String getJavascriptBridgeForReadyAvailableData(String blobUrl, String m " var r = new FileReader();" + " var blob = _file.slice(_offset, length + _offset);" + " r.onload = onLoadHandler;" + - " r.readAsArrayBuffer(blob);" + + " r.readAsBinaryString(blob);" + " };" + " readBlock(offset, chunkSize, file);" + "};\n" + "(() => { let xhr = new XMLHttpRequest();" + "xhr.open('GET', '"+ blobUrl +"', true);" + ((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Content-type','" + mimeType + "');" : "") + - "xhr.responseType = 'blob';console.log('fetchingg');" + - "xhr.onerror = xhr.onload = function(e) {console.log('fetched', this.status);" + + "xhr.responseType = 'blob';" + + "xhr.onerror = xhr.onload = function(e) {" + " if (this.status == 200) {" + + " let contentType = this.getResponseHeader('content-type');" + + " if (contentType) { CapacitorDownloadInterface.receiveContentTypeFromJavascript(contentType, '" + operationID + "'); }" + " var blob = this.response;" + " parseFile(blob, " + " function(chunk) { CapacitorDownloadInterface.receiveStreamChunkFromJavascript(chunk, '" + operationID + "'); }," + - " function(err) { console.error(err); CapacitorDownloadInterface.receiveStreamChunkFromJavascript(err.message, '" + operationID + "'); }, " + - " function() { console.log('capacitor bridge, drained!'); CapacitorDownloadInterface.receiveStreamCompletionFromJavascript('" + operationID + "'); } " + + " function(err) { console.error('[Capacitor XHR] - error:', err); CapacitorDownloadInterface.receiveStreamChunkFromJavascript(err.message, '" + operationID + "'); }, " + + " function() { console.log('[Capacitor XHR] - Drained!'); CapacitorDownloadInterface.receiveStreamCompletionFromJavascript('" + operationID + "'); } " + " );" + " } else {" + " console.error('[Capacitor XHR] - error:', this.status, (e ? e.loaded : this.responseText));" + @@ -157,4 +144,20 @@ private String getJavascriptBridgeForReadyAvailableData(String blobUrl, String m "};" + "xhr.send();})()"; } + + /* Helpers */ + private void transitionPendingInputOperation(String operationID, @Nullable String optionalContentType, @Nullable Boolean doNotStart) { + //Check if have pending input operation, if not, we discard this content type resolution + //for some awkward reason the chunk was received before + DownloadJSOperationController.Input input = this.pendingInputs.get(operationID); + if (input == null) return; + //Set content type if available (override, no problem with that) + if (optionalContentType != null) { + Logger.debug("Received content type", optionalContentType); + input.optionalMimeType = optionalContentType; + } + //Start operation + this.pendingInputs.remove(operationID); + if (doNotStart == null || !doNotStart) this.launcher.launch(input); + } } diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java index 6da9f5de2..efc472de8 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java @@ -11,8 +11,8 @@ * dynamic javascript upon the 'protocol' interface aviability. */ public class DownloadJSProxy implements android.webkit.DownloadListener { - private Bridge bridge; - private DownloadJSInterface downloadInterface; + final private Bridge bridge; + final private DownloadJSInterface downloadInterface; public DownloadJSProxy(Bridge bridge) { this.bridge = bridge; this.downloadInterface = new DownloadJSInterface(this.bridge.getActivity()); From 1f01c450645dab5335af03797b613bf03598d0e8 Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Fri, 11 Mar 2022 12:33:47 -0800 Subject: [PATCH 06/14] Intercept known chrome mimetype blobs and spawn same download process --- .../main/java/com/getcapacitor/Bridge.java | 15 ++++++++---- .../com/getcapacitor/DownloadJSProxy.java | 23 +++++++++++++++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java index 1e7aa7052..34209371b 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java +++ b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java @@ -125,6 +125,7 @@ public class Bridge { private Boolean canInjectJS = true; // A reference to the main WebView for the app private final WebView webView; + public final DownloadJSProxy downloadProxy; public final MockCordovaInterfaceImpl cordovaInterface; private CordovaWebView cordovaWebView; private CordovaPreferences preferences; @@ -207,6 +208,7 @@ private Bridge( this.fragment = fragment; this.webView = webView; this.webViewClient = new BridgeWebViewClient(this); + this.downloadProxy = new DownloadJSProxy(this); this.initialPlugins = initialPlugins; this.pluginInstances = pluginInstances; this.cordovaInterface = cordovaInterface; @@ -417,6 +419,12 @@ public boolean launchIntent(Uri url) { } return true; } + + /* Maybe handle blobs URI */ + if (this.downloadProxy.shouldOverrideLoad(url.toString())) { + return true; + } + return false; } @@ -581,14 +589,13 @@ public void reset() { private void initWebView() { WebSettings settings = webView.getSettings(); settings.setJavaScriptEnabled(true); + settings.setAllowFileAccess(true); + webView.addJavascriptInterface(this.downloadProxy.jsInterface(), this.downloadProxy.jsInterfaceName()); + webView.setDownloadListener(this.downloadProxy); settings.setDomStorageEnabled(true); settings.setGeolocationEnabled(true); settings.setMediaPlaybackRequiresUserGesture(false); settings.setJavaScriptCanOpenWindowsAutomatically(true); - DownloadJSProxy downloadProxy = new DownloadJSProxy(this); - webView.addJavascriptInterface(downloadProxy.jsInterface(), downloadProxy.jsInterfaceName()); - webView.setDownloadListener(downloadProxy); - if (this.config.isMixedContentAllowed()) { settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); } diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java index efc472de8..8795f36b9 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java @@ -8,7 +8,7 @@ /** * Represents the bridge.webview download proxy to jsInterface (DownloadJSInterface class). * Every download request from webview will be sent to the proxy, which decides to inject - * dynamic javascript upon the 'protocol' interface aviability. + * dynamic javascript upon the 'protocol' interface availability. */ public class DownloadJSProxy implements android.webkit.DownloadListener { final private Bridge bridge; @@ -22,6 +22,25 @@ public DownloadJSProxy(Bridge bridge) { // public DownloadJSInterface jsInterface() { return this.downloadInterface; } public String jsInterfaceName() { return "CapacitorDownloadInterface"; } + + /* Public interceptors */ + public boolean shouldOverrideLoad(String url) { + //Only override blobs URIs (do not leave up to the interface because + //it does accept http/https schemas + if (!url.startsWith("blob:")) return false; + //Debug + Logger.debug("Capacitor webview intercepted blob download request", url); + //Check if we can handle the URL.. + String bridge = this.downloadInterface.getJavascriptBridgeForURL(url, null, null); + if (bridge != null) { + this.bridge.getWebView().loadUrl(bridge); + return true; + } else { + Logger.info("Capacitor webview download has no handler for the following url", url); + return false; + } + } + /* Public DownloadListener implementation */ @Override public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimeType, long contentLength) { //Debug @@ -36,7 +55,7 @@ public void onDownloadStart(String url, String userAgent, String contentDisposit } } - // + /* Private utils */ private void installServiceWorkerProxy() { //Downloads can be done via webworker, webworkers might need local resources, we enable that if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { From b2ef3b51f04606c342e142f27e70a660349816fa Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Fri, 11 Mar 2022 12:49:38 -0800 Subject: [PATCH 07/14] Fix mime type issue and copy to capacitor --- ...Activity.java => DownloadJSInterface.java} | 14 +- .../DownloadJSOperationController.java | 391 ++++++++++++------ 2 files changed, 264 insertions(+), 141 deletions(-) rename android/capacitor/src/main/java/com/getcapacitor/{DownloadJSActivity.java => DownloadJSInterface.java} (96%) diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSActivity.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java similarity index 96% rename from android/capacitor/src/main/java/com/getcapacitor/DownloadJSActivity.java rename to android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java index 5ed854384..d7aa6053f 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSActivity.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java @@ -119,7 +119,7 @@ public void completeOperation(String operationID) { @NonNull public Intent createIntent(@NonNull Context context, DownloadJSOperationController.Input input) { //ask path - String[] paths = this.getUniqueDownloadFileNameFromDetails(input.fileNameURL, input.optionalMimeType, input.contentDisposition, null); + String[] paths = this.getUniqueDownloadFileNameFromDetails(input.fileNameURL, input.contentDisposition, input.optionalMimeType, null); //Create/config intent to prompt for file selection Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); @@ -275,12 +275,12 @@ private String[] getUniqueDownloadFileNameFromDetails(String fileDownloadURL, St if (!this.checkCreateDefaultDir()) return null; //Check if file with generated name exists String fullPath = this.getDownloadFilePath(fileName); - //File picker should do this for us - File file = new File(fullPath); - if (file.exists()) { - Integer nextSuffix = (optionalSuffix != null ? optionalSuffix + 1 : 1); - return this.getUniqueDownloadFileNameFromDetails(fileDownloadURL, optionalCD, optionalMimeType, nextSuffix); - } + //Comment since file picker should do this for us +// File file = new File(fullPath); +// if (file.exists()) { +// Integer nextSuffix = (optionalSuffix != null ? optionalSuffix + 1 : 1); +// return this.getUniqueDownloadFileNameFromDetails(fileDownloadURL, optionalCD, optionalMimeType, nextSuffix); +// } return new String[]{fullPath, fileName}; } } diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java index bdd3dd6c9..d7aa6053f 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java @@ -1,163 +1,286 @@ package com.getcapacitor; -import android.Manifest; import android.content.Context; import android.content.Intent; -import android.content.pm.PackageManager; +import android.app.Activity; +import android.media.MediaScannerConnection; +import android.net.Uri; import android.os.Build; import android.os.Environment; import android.provider.DocumentsContract; import android.text.TextUtils; -import android.webkit.JavascriptInterface; import android.webkit.URLUtil; -import androidx.activity.result.ActivityResultCallback; -import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContract; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; -import java.util.HashMap; -import java.util.UUID; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.io.OutputStreamWriter; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.Executors; + +public class DownloadJSOperationController extends ActivityResultContract { + /* DownloadJSActivity Input */ + public static class Input { + public String fileNameURL; + public String optionalMimeType; + public String contentDisposition; + public String operationID; + public Input(String operationID, String fileNameURL, String optionalMimeType, String contentDisposition) { + this.operationID = operationID; + this.fileNameURL = fileNameURL; + this.optionalMimeType = optionalMimeType; + this.contentDisposition = contentDisposition; + } + } + /* DownloadJSActivity internal operation */ + public static class Operation { + final private Input input; + public String operationID; + public PipedOutputStream outStream; + public PipedInputStream inStream; + //state + public Boolean closed; + public Boolean started; + public Boolean pendingClose; + public Boolean failureClose; + // + public Operation(Input input) { + this.input = input; + this.operationID = input.operationID; + this.closed = this.started = this.pendingClose = this.failureClose = false; + this.outStream = new PipedOutputStream(); + try { + this.inStream = new PipedInputStream(1024 * 64); + this.inStream.connect(this.outStream); + } catch (IOException e) { + this.failureClose = true; + this.pendingClose = true; + Logger.debug("Exception while opening/connecting DownloadJSActivity streams.", e.toString()); + } + } + } -/** - * Represents the bridge.webview exposed JS download interface + proxy interface injector. - * Every download request from webview will have their URLs + mime, content-disposition - * analyzed in order to determine if we do have a injector that supports it and return - * to the proxy in order to have that code executed exclusively for that request. - */ -public class DownloadJSInterface { - final private DownloadJSOperationController operationsController; - final private ActivityResultLauncher launcher; - final private HashMap pendingInputs; + /* DownloadJSActivity */ + final private static String EXTRA_OPERATION_ID = "OPERATION_ID"; + final private AppCompatActivity activity; + final private HashMap operations; + private Operation pendingOperation; // - public DownloadJSInterface(AppCompatActivity activity) { - this.operationsController = new DownloadJSOperationController(activity); - this.pendingInputs = new HashMap<>(); - this.launcher = activity.registerForActivityResult(this.operationsController, - new ActivityResultCallback() { - @Override - public void onActivityResult(Boolean result) { - Logger.debug("DownloadJSActivity result", String.valueOf(result)); - } - }); + public DownloadJSOperationController(AppCompatActivity activity) { + this.activity = activity; + this.operations = new HashMap<>(); } - /* JavascriptInterface imp. */ - @JavascriptInterface - public void receiveContentTypeFromJavascript(String contentType, String operationID) { - this.transitionPendingInputOperation(operationID, contentType, false); - } - @JavascriptInterface - public void receiveStreamChunkFromJavascript(String chunk, String operationID) { -// Logger.debug("Received stream", chunk); -// Logger.debug("Received stream2", operationID); - this.transitionPendingInputOperation(operationID, null, null); - //Check if activity has started already - this.operationsController.appendToOperation(operationID, chunk); - } - @JavascriptInterface - public void receiveStreamErrorFromJavascript(String error, String operationID) { -// Logger.debug("Received error", error + " - " + operationID); - this.transitionPendingInputOperation(operationID, null, true); - this.operationsController.failOperation(operationID); - } - @JavascriptInterface - public void receiveStreamCompletionFromJavascript(String operationID) { -// Logger.debug("Operation completed", operationID); - this.operationsController.completeOperation(operationID); + /* Public operations */ + public void appendToOperation(String operationID, String data) { + //get operation status + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null || operation.closed) return; //already closed? + //write + try { + operation.outStream.write(data.getBytes(StandardCharsets.ISO_8859_1)); + } catch (IOException e) { + Logger.debug("Exception while writting on DownloadJSActivity stream. Closing it!", e.toString()); + //Ask for close + operation.pendingClose = true; + } + } + public void failOperation(String operationID) { + //get operation status + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null || operation.closed) return; //already closed? + //Ask for close + operation.failureClose = true; + operation.pendingClose = true; + } + public void completeOperation(String operationID) { + //get operation status + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null || operation.closed) return; //already closed? + //Ask for close + operation.pendingClose = true; } - /* Proxy injector - * This code analyze incoming download requests and return appropriated JS injectors. - * Injectors, handle the download request at the browser context and call the JSInterface - * with chunks of data to be written on the disk. This technic is specially useful for - * blobs and webworker initiated downloads. - */ - public String getJavascriptBridgeForURL(String fileURL, String contentDisposition, String mimeType) { - if (fileURL.startsWith("http://") || fileURL.startsWith("https://") || fileURL.startsWith("blob:")) { - //setup background operation input (not started yet) - //will wait either stream start on content-type resolution to start asking - //for file pick and stream drain - String operationID = UUID.randomUUID().toString(); - DownloadJSOperationController.Input input = new DownloadJSOperationController.Input(operationID, fileURL, mimeType, contentDisposition); - this.pendingInputs.put(operationID, input); - //Return JS bridge with operationID tagged - return this.getJavascriptInterfaceBridgeForReadyAvailableData(fileURL, mimeType, operationID); + + /* ActivityResultContract Implementation */ + @NonNull + public Intent createIntent(@NonNull Context context, DownloadJSOperationController.Input input) { + //ask path + String[] paths = this.getUniqueDownloadFileNameFromDetails(input.fileNameURL, input.contentDisposition, input.optionalMimeType, null); + //Create/config intent to prompt for file selection + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + if (paths != null && paths[1] != null) intent.putExtra(Intent.EXTRA_TITLE, paths[1]); + intent.putExtra(EXTRA_OPERATION_ID, input.operationID); + if (input.optionalMimeType != null) intent.setType(input.optionalMimeType); + if (paths != null && paths[0] != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, paths[0]); + //Add operation + this.pendingOperation = new Operation(input); + // + return intent; + } + public Boolean parseResult(int resultCode, @Nullable Intent result) { + //get operation status + Operation operation = this.pendingOperation; + if (operation == null) return false; //double call? + //Process if resultCode is OK and have result + if (resultCode == Activity.RESULT_OK && result != null) { + this.operations.put(operation.input.operationID, operation); + this.pendingOperation = null; + this.createThreadedPipeForOperation(operation, result.getData()); + return true; } - return null; - } - /* Injectors */ - private String getJavascriptInterfaceBridgeForReadyAvailableData(String blobUrl, String mimeType, String operationID) { - return "javascript: " + - "" + - "function parseFile(file, chunkReadCallback, errorCallback, successCallback) {\n" + - " let fileSize = file.size;" + - " let chunkSize = 64 * 1024;" + - " let offset = 0;" + - " let self = this;" + - " let readBlock = null;" + - " let onLoadHandler = function(evt) {" + - " if (evt.target.error == null) {" + - " offset += evt.target.result.length;" + - " chunkReadCallback(evt.target.result);" + - " } else {" + - " errorCallback(evt.target.error);" + - " return;" + - " }" + - " if (offset >= fileSize) {" + - " if (successCallback) successCallback();" + - " return;" + - " }" + - " readBlock(offset, chunkSize, file);" + - " };" + - " readBlock = function(_offset, length, _file) {" + - " var r = new FileReader();" + - " var blob = _file.slice(_offset, length + _offset);" + - " r.onload = onLoadHandler;" + - " r.readAsBinaryString(blob);" + - " };" + - " readBlock(offset, chunkSize, file);" + - "};\n" + - "(() => { let xhr = new XMLHttpRequest();" + - "xhr.open('GET', '"+ blobUrl +"', true);" + - ((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Content-type','" + mimeType + "');" : "") + - "xhr.responseType = 'blob';" + - "xhr.onerror = xhr.onload = function(e) {" + - " if (this.status == 200) {" + - " let contentType = this.getResponseHeader('content-type');" + - " if (contentType) { CapacitorDownloadInterface.receiveContentTypeFromJavascript(contentType, '" + operationID + "'); }" + - " var blob = this.response;" + - " parseFile(blob, " + - " function(chunk) { CapacitorDownloadInterface.receiveStreamChunkFromJavascript(chunk, '" + operationID + "'); }," + - " function(err) { console.error('[Capacitor XHR] - error:', err); CapacitorDownloadInterface.receiveStreamChunkFromJavascript(err.message, '" + operationID + "'); }, " + - " function() { console.log('[Capacitor XHR] - Drained!'); CapacitorDownloadInterface.receiveStreamCompletionFromJavascript('" + operationID + "'); } " + - " );" + - " } else {" + - " console.error('[Capacitor XHR] - error:', this.status, (e ? e.loaded : this.responseText));" + - " }" + - "};" + - "xhr.send();})()"; + //Cancel pre operation (haven't started yet) + this.pendingOperation = null; //can't be used for writting anymore + this.cancelPreOperation(operation); + return false; } - /* Helpers */ - private void transitionPendingInputOperation(String operationID, @Nullable String optionalContentType, @Nullable Boolean doNotStart) { - //Check if have pending input operation, if not, we discard this content type resolution - //for some awkward reason the chunk was received before - DownloadJSOperationController.Input input = this.pendingInputs.get(operationID); - if (input == null) return; - //Set content type if available (override, no problem with that) - if (optionalContentType != null) { - Logger.debug("Received content type", optionalContentType); - input.optionalMimeType = optionalContentType; + //Thread operation that uses duplex stream + private void createThreadedPipeForOperation(Operation operation, Uri uri) { + DownloadJSOperationController upperRef = this; + Executors.newSingleThreadExecutor().execute(new Runnable() { + @Override + public void run() { + upperRef.createPipeForOperation(operation, uri); + } + }); + } + private void createPipeForOperation(Operation operation, Uri uri) { + //check for operation finished + if (operation.started || operation.closed) return; + //start operation + operation.started = true; + // + try { + OutputStream output = this.activity.getContentResolver().openOutputStream(uri); + int lastReadSize = 0; + boolean flushed = false; + while (!operation.pendingClose || lastReadSize > 0 || !flushed) { + //Have what to read? + lastReadSize = Math.min(operation.inStream.available(), 64 * 1024); + if (lastReadSize <= 0) { + //read size is 0, attempt to flush duplex and make sure we got everything + if (!flushed) { + operation.outStream.flush(); + flushed = true; + } + continue; + } + //Reset flushed state if we got more data + flushed = false; + //Read + byte[] bytes = new byte[lastReadSize]; + lastReadSize = operation.inStream.read(bytes, 0, lastReadSize); + output.write(bytes); + } + //Close streams + output.flush(); //IO flush + output.close(); + operation.closed = true; + operation.outStream.close(); + operation.inStream.close(); + //Release from operations + this.releaseOperation(operation.input.operationID); + //Ask for media scan + this.performMediaScan(uri); + } catch (Exception e) { + Logger.debug("Exception while running DownloadJSActivity threaded operation.", e.toString()); + //Cancel operation stream (safely) and release from operations + this.cancelPreOperation(operation); + this.releaseOperation(operation.input.operationID); + } + Logger.debug("DownloadJSActivity completed!", operation.input.operationID); + } + + /* Operation Utils */ + private void cancelPreOperation(Operation operation) { + operation.pendingClose = true; + operation.closed = true; + try { + operation.outStream.close(); + operation.inStream.close(); + } catch (IOException ignored) { } //failsafe stream close + } + private void releaseOperation(String operationID) { + //get operation status + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null) return; //already closed? + //Check for pending closure (loop interruption) + if (!operation.pendingClose) operation.pendingClose = true; + //Remove from operations + this.operations.remove(operation.input.operationID); + } + + /* Media utils */ + private void performMediaScan(Uri uri) { + // Tell the media scanner about the new file so that it is + // immediately available to the user. + MediaScannerConnection.scanFile(this.activity, + new String[] { uri.toString() }, null, + new MediaScannerConnection.OnScanCompletedListener() { + public void onScanCompleted(String path, Uri uri2) { + Logger.debug("ExternalStorage", "Scanned " + path + ":"); + Logger.debug("ExternalStorage", "-> uri=" + uri2); + } + }); + } + + /* FS Utils */ + private String getDownloadFilePath(String fileName) { + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() + '/' + fileName; + } + private boolean checkCreateDefaultDir() { + boolean created = false; + try { + File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + if (!dir.exists()) { + if (dir.mkdir()) created = true; + } else created = true; + } catch (RuntimeException e) { + Logger.debug("Error while creating default download dir:", e.toString()); + } + return created; + } + private String[] getUniqueDownloadFileNameFromDetails(String fileDownloadURL, String optionalCD, String optionalMimeType, @Nullable Integer optionalSuffix) { + //Auxs for filename gen. + String suggestedFilename = URLUtil.guessFileName(fileDownloadURL, optionalCD, optionalMimeType); + ArrayList fileComps = new ArrayList<>(Arrays.asList(suggestedFilename.split("."))); + String suffix = (optionalSuffix != null ? " (" + optionalSuffix + ")" : ""); + //Check for invalid filename + if (suggestedFilename.length() <= 0) suggestedFilename = UUID.randomUUID().toString(); + //Generate filename + String fileName; + if (fileComps.size() > 1) { + String fileExtension = "." + fileComps.remove(fileComps.size() - 1); + fileName = TextUtils.join(".", fileComps) + suffix + fileExtension; + } else { + fileName = suggestedFilename + suffix; } - //Start operation - this.pendingInputs.remove(operationID); - if (doNotStart == null || !doNotStart) this.launcher.launch(input); + //Check for default dir (might not exists per official documentation) + if (!this.checkCreateDefaultDir()) return null; + //Check if file with generated name exists + String fullPath = this.getDownloadFilePath(fileName); + //Comment since file picker should do this for us +// File file = new File(fullPath); +// if (file.exists()) { +// Integer nextSuffix = (optionalSuffix != null ? optionalSuffix + 1 : 1); +// return this.getUniqueDownloadFileNameFromDetails(fileDownloadURL, optionalCD, optionalMimeType, nextSuffix); +// } + return new String[]{fullPath, fileName}; } } From e71a8d2824f68f2480928883bd96b4c790590132 Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Fri, 11 Mar 2022 14:41:04 -0800 Subject: [PATCH 08/14] Tweaks on android + add notification to application (yet, needs plugin implementation) --- .../src/main/java/com/getcapacitor/App.java | 26 ++ .../com/getcapacitor/DownloadJSInterface.java | 399 ++++++------------ .../DownloadJSOperationController.java | 34 +- .../com/getcapacitor/DownloadJSProxy.java | 2 +- 4 files changed, 175 insertions(+), 286 deletions(-) diff --git a/android/capacitor/src/main/java/com/getcapacitor/App.java b/android/capacitor/src/main/java/com/getcapacitor/App.java index f46b6332b..3b3713b5c 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/App.java +++ b/android/capacitor/src/main/java/com/getcapacitor/App.java @@ -18,12 +18,24 @@ public interface AppRestoredListener { void onAppRestored(PluginResult result); } + public enum DownloadStatus { STARTED, COMPLETED, FAILED } + + /** + * Interface for callbacks when app is receives download request from webview. + */ + public interface AppDownloadListener { + void onAppDownloadUpdate(String operationID, DownloadStatus operationStatus, @Nullable String error); + } + @Nullable private AppStatusChangeListener statusChangeListener; @Nullable private AppRestoredListener appRestoredListener; + @Nullable + private AppDownloadListener appDownloadListener; + private boolean isActive = false; public boolean isActive() { @@ -46,6 +58,14 @@ public void setAppRestoredListener(@Nullable AppRestoredListener listener) { this.appRestoredListener = listener; } + /** + * Set the object to receive callbacks. + * @param listener + */ + public void setAppDownloadListener(@Nullable AppDownloadListener listener) { + this.appDownloadListener = listener; + } + protected void fireRestoredResult(PluginResult result) { if (appRestoredListener != null) { appRestoredListener.onAppRestored(result); @@ -58,4 +78,10 @@ public void fireStatusChange(boolean isActive) { statusChangeListener.onAppStatusChanged(isActive); } } + + public void fireDownloadUpdate(String operationID, DownloadStatus operationStatus, @Nullable String error) { + if (appDownloadListener != null) { + appDownloadListener.onAppDownloadUpdate(operationID, operationStatus, error); + } + } } diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java index d7aa6053f..56a71f416 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java @@ -1,286 +1,151 @@ package com.getcapacitor; -import android.content.Context; -import android.content.Intent; -import android.app.Activity; -import android.media.MediaScannerConnection; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.provider.DocumentsContract; -import android.text.TextUtils; -import android.webkit.URLUtil; +import android.webkit.JavascriptInterface; -import androidx.activity.result.contract.ActivityResultContract; -import androidx.annotation.NonNull; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import java.io.File; -import java.io.IOException; -import java.io.OutputStream; -import java.io.PipedInputStream; -import java.io.PipedOutputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.UUID; -import java.util.concurrent.Executors; -public class DownloadJSOperationController extends ActivityResultContract { - /* DownloadJSActivity Input */ - public static class Input { - public String fileNameURL; - public String optionalMimeType; - public String contentDisposition; - public String operationID; - public Input(String operationID, String fileNameURL, String optionalMimeType, String contentDisposition) { - this.operationID = operationID; - this.fileNameURL = fileNameURL; - this.optionalMimeType = optionalMimeType; - this.contentDisposition = contentDisposition; - } - } - /* DownloadJSActivity internal operation */ - public static class Operation { - final private Input input; - public String operationID; - public PipedOutputStream outStream; - public PipedInputStream inStream; - //state - public Boolean closed; - public Boolean started; - public Boolean pendingClose; - public Boolean failureClose; - // - public Operation(Input input) { - this.input = input; - this.operationID = input.operationID; - this.closed = this.started = this.pendingClose = this.failureClose = false; - this.outStream = new PipedOutputStream(); - try { - this.inStream = new PipedInputStream(1024 * 64); - this.inStream.connect(this.outStream); - } catch (IOException e) { - this.failureClose = true; - this.pendingClose = true; - Logger.debug("Exception while opening/connecting DownloadJSActivity streams.", e.toString()); - } - } - } - - /* DownloadJSActivity */ - final private static String EXTRA_OPERATION_ID = "OPERATION_ID"; - final private AppCompatActivity activity; - final private HashMap operations; - private Operation pendingOperation; +/** + * Represents the bridge.webview exposed JS download interface + proxy interface injector. + * Every download request from webview will have their URLs + mime, content-disposition + * analyzed in order to determine if we do have a injector that supports it and return + * to the proxy in order to have that code executed exclusively for that request. + */ +public class DownloadJSInterface { + final private DownloadJSOperationController operationsController; + final private ActivityResultLauncher launcher; + final private HashMap pendingInputs; + final private Bridge bridge; // - public DownloadJSOperationController(AppCompatActivity activity) { - this.activity = activity; - this.operations = new HashMap<>(); + public DownloadJSInterface(Bridge bridge) { + this.operationsController = new DownloadJSOperationController(bridge.getActivity()); + this.pendingInputs = new HashMap<>(); + this.bridge = bridge; + this.launcher = bridge.getActivity().registerForActivityResult(this.operationsController, + result -> Logger.debug("DownloadJSActivity result", String.valueOf(result))); } - /* Public operations */ - public void appendToOperation(String operationID, String data) { - //get operation status - Operation operation = this.operations.get(operationID); - if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; - if (operation == null || operation.closed) return; //already closed? - //write - try { - operation.outStream.write(data.getBytes(StandardCharsets.ISO_8859_1)); - } catch (IOException e) { - Logger.debug("Exception while writting on DownloadJSActivity stream. Closing it!", e.toString()); - //Ask for close - operation.pendingClose = true; - } - } - public void failOperation(String operationID) { - //get operation status - Operation operation = this.operations.get(operationID); - if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; - if (operation == null || operation.closed) return; //already closed? - //Ask for close - operation.failureClose = true; - operation.pendingClose = true; - } - public void completeOperation(String operationID) { - //get operation status - Operation operation = this.operations.get(operationID); - if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; - if (operation == null || operation.closed) return; //already closed? - //Ask for close - operation.pendingClose = true; - } - - - /* ActivityResultContract Implementation */ - @NonNull - public Intent createIntent(@NonNull Context context, DownloadJSOperationController.Input input) { - //ask path - String[] paths = this.getUniqueDownloadFileNameFromDetails(input.fileNameURL, input.contentDisposition, input.optionalMimeType, null); - //Create/config intent to prompt for file selection - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - if (paths != null && paths[1] != null) intent.putExtra(Intent.EXTRA_TITLE, paths[1]); - intent.putExtra(EXTRA_OPERATION_ID, input.operationID); - if (input.optionalMimeType != null) intent.setType(input.optionalMimeType); - if (paths != null && paths[0] != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, paths[0]); - //Add operation - this.pendingOperation = new Operation(input); - // - return intent; - } - public Boolean parseResult(int resultCode, @Nullable Intent result) { - //get operation status - Operation operation = this.pendingOperation; - if (operation == null) return false; //double call? - //Process if resultCode is OK and have result - if (resultCode == Activity.RESULT_OK && result != null) { - this.operations.put(operation.input.operationID, operation); - this.pendingOperation = null; - this.createThreadedPipeForOperation(operation, result.getData()); - return true; - } - //Cancel pre operation (haven't started yet) - this.pendingOperation = null; //can't be used for writting anymore - this.cancelPreOperation(operation); - return false; + /* JavascriptInterface imp. */ + @JavascriptInterface + public void receiveContentTypeFromJavascript(String contentType, String operationID) { + //Transition pending input operation to started with resolved content type + this.transitionPendingInputOperation(operationID, contentType, null); + } + @JavascriptInterface + public void receiveStreamChunkFromJavascript(String chunk, String operationID) { + //Guarantee pending input transition to started operation (when no content type is resolved) + this.transitionPendingInputOperation(operationID, null, null); + //Append data to operation + this.operationsController.appendToOperation(operationID, chunk); + } + @JavascriptInterface + public void receiveStreamErrorFromJavascript(String error, String operationID) { + //Guarantee pending input transition to 'started-but-stale' operation before actually failing + this.transitionPendingInputOperation(operationID, null, true); + //Fail operation signal + if (!this.operationsController.failOperation(operationID)) return; + //Notify + this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.FAILED, error); + } + @JavascriptInterface + public void receiveStreamCompletionFromJavascript(String operationID) { + //Complete operation signal + if (!this.operationsController.completeOperation(operationID)) return; + //Notify + this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.COMPLETED, null); } - //Thread operation that uses duplex stream - private void createThreadedPipeForOperation(Operation operation, Uri uri) { - DownloadJSOperationController upperRef = this; - Executors.newSingleThreadExecutor().execute(new Runnable() { - @Override - public void run() { - upperRef.createPipeForOperation(operation, uri); - } - }); - } - private void createPipeForOperation(Operation operation, Uri uri) { - //check for operation finished - if (operation.started || operation.closed) return; - //start operation - operation.started = true; - // - try { - OutputStream output = this.activity.getContentResolver().openOutputStream(uri); - int lastReadSize = 0; - boolean flushed = false; - while (!operation.pendingClose || lastReadSize > 0 || !flushed) { - //Have what to read? - lastReadSize = Math.min(operation.inStream.available(), 64 * 1024); - if (lastReadSize <= 0) { - //read size is 0, attempt to flush duplex and make sure we got everything - if (!flushed) { - operation.outStream.flush(); - flushed = true; - } - continue; - } - //Reset flushed state if we got more data - flushed = false; - //Read - byte[] bytes = new byte[lastReadSize]; - lastReadSize = operation.inStream.read(bytes, 0, lastReadSize); - output.write(bytes); - } - //Close streams - output.flush(); //IO flush - output.close(); - operation.closed = true; - operation.outStream.close(); - operation.inStream.close(); - //Release from operations - this.releaseOperation(operation.input.operationID); - //Ask for media scan - this.performMediaScan(uri); - } catch (Exception e) { - Logger.debug("Exception while running DownloadJSActivity threaded operation.", e.toString()); - //Cancel operation stream (safely) and release from operations - this.cancelPreOperation(operation); - this.releaseOperation(operation.input.operationID); + /* Proxy injector + * This code analyze incoming download requests and return appropriated JS injectors. + * Injectors, handle the download request at the browser context and call the JSInterface + * with chunks of data to be written on the disk. This technic is specially useful for + * blobs and webworker initiated downloads. + */ + public String getJavascriptBridgeForURL(String fileURL, String contentDisposition, String mimeType) { + if (fileURL.startsWith("http://") || fileURL.startsWith("https://") || fileURL.startsWith("blob:")) { + //setup background operation input (not started yet) + //will wait either stream start on content-type resolution to start asking + //for file pick and stream drain + String operationID = UUID.randomUUID().toString(); + DownloadJSOperationController.Input input = new DownloadJSOperationController.Input(operationID, fileURL, mimeType, contentDisposition); + this.pendingInputs.put(operationID, input); + //Return JS bridge with operationID tagged + return this.getJavascriptInterfaceBridgeForReadyAvailableData(fileURL, mimeType, operationID); } - Logger.debug("DownloadJSActivity completed!", operation.input.operationID); - } - - /* Operation Utils */ - private void cancelPreOperation(Operation operation) { - operation.pendingClose = true; - operation.closed = true; - try { - operation.outStream.close(); - operation.inStream.close(); - } catch (IOException ignored) { } //failsafe stream close - } - private void releaseOperation(String operationID) { - //get operation status - Operation operation = this.operations.get(operationID); - if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; - if (operation == null) return; //already closed? - //Check for pending closure (loop interruption) - if (!operation.pendingClose) operation.pendingClose = true; - //Remove from operations - this.operations.remove(operation.input.operationID); + return null; + } + /* Injectors */ + private String getJavascriptInterfaceBridgeForReadyAvailableData(String blobUrl, String mimeType, String operationID) { + return "javascript: " + + "" + + "function parseFile(file, chunkReadCallback, errorCallback, successCallback) {\n" + + " let fileSize = file.size;" + + " let chunkSize = 64 * 1024;" + + " let offset = 0;" + + " let self = this;" + + " let readBlock = null;" + + " let onLoadHandler = function(evt) {" + + " if (evt.target.error == null) {" + + " offset += evt.target.result.length;" + + " chunkReadCallback(evt.target.result);" + + " } else {" + + " errorCallback(evt.target.error);" + + " return;" + + " }" + + " if (offset >= fileSize) {" + + " if (successCallback) successCallback();" + + " return;" + + " }" + + " readBlock(offset, chunkSize, file);" + + " };" + + " readBlock = function(_offset, length, _file) {" + + " var r = new FileReader();" + + " var blob = _file.slice(_offset, length + _offset);" + + " r.onload = onLoadHandler;" + + " r.readAsBinaryString(blob);" + + " };" + + " readBlock(offset, chunkSize, file);" + + "};\n" + + "(() => { let xhr = new XMLHttpRequest();" + + "xhr.open('GET', '"+ blobUrl +"', true);" + + ((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Content-type','" + mimeType + "');" : "") + + "xhr.responseType = 'blob';" + + "xhr.onerror = xhr.onload = function(e) {" + + " if (this.status == 200) {" + + " let contentType = this.getResponseHeader('content-type');" + + " if (contentType) { CapacitorDownloadInterface.receiveContentTypeFromJavascript(contentType, '" + operationID + "'); }" + + " var blob = this.response;" + + " parseFile(blob, " + + " function(chunk) { CapacitorDownloadInterface.receiveStreamChunkFromJavascript(chunk, '" + operationID + "'); }," + + " function(err) { console.error('[Capacitor XHR] - error:', err); CapacitorDownloadInterface.receiveStreamChunkFromJavascript(err.message, '" + operationID + "'); }, " + + " function() { console.log('[Capacitor XHR] - Drained!'); CapacitorDownloadInterface.receiveStreamCompletionFromJavascript('" + operationID + "'); } " + + " );" + + " } else {" + + " console.error('[Capacitor XHR] - error:', this.status, (e ? e.loaded : this.responseText));" + + " }" + + "};" + + "xhr.send();})()"; } - /* Media utils */ - private void performMediaScan(Uri uri) { - // Tell the media scanner about the new file so that it is - // immediately available to the user. - MediaScannerConnection.scanFile(this.activity, - new String[] { uri.toString() }, null, - new MediaScannerConnection.OnScanCompletedListener() { - public void onScanCompleted(String path, Uri uri2) { - Logger.debug("ExternalStorage", "Scanned " + path + ":"); - Logger.debug("ExternalStorage", "-> uri=" + uri2); - } - }); - } - - /* FS Utils */ - private String getDownloadFilePath(String fileName) { - return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() + '/' + fileName; - } - private boolean checkCreateDefaultDir() { - boolean created = false; - try { - File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - if (!dir.exists()) { - if (dir.mkdir()) created = true; - } else created = true; - } catch (RuntimeException e) { - Logger.debug("Error while creating default download dir:", e.toString()); - } - return created; - } - private String[] getUniqueDownloadFileNameFromDetails(String fileDownloadURL, String optionalCD, String optionalMimeType, @Nullable Integer optionalSuffix) { - //Auxs for filename gen. - String suggestedFilename = URLUtil.guessFileName(fileDownloadURL, optionalCD, optionalMimeType); - ArrayList fileComps = new ArrayList<>(Arrays.asList(suggestedFilename.split("."))); - String suffix = (optionalSuffix != null ? " (" + optionalSuffix + ")" : ""); - //Check for invalid filename - if (suggestedFilename.length() <= 0) suggestedFilename = UUID.randomUUID().toString(); - //Generate filename - String fileName; - if (fileComps.size() > 1) { - String fileExtension = "." + fileComps.remove(fileComps.size() - 1); - fileName = TextUtils.join(".", fileComps) + suffix + fileExtension; - } else { - fileName = suggestedFilename + suffix; + /* Helpers */ + private void transitionPendingInputOperation(String operationID, @Nullable String optionalContentType, @Nullable Boolean doNotStart) { + //Check if have pending input operation, if not, we discard this content type resolution + //for some awkward reason the chunk was received before + DownloadJSOperationController.Input input = this.pendingInputs.get(operationID); + if (input == null) return; + //Set content type if available (override, no problem with that) + if (optionalContentType != null) { + Logger.debug("Received content type", optionalContentType); + input.optionalMimeType = optionalContentType; } - //Check for default dir (might not exists per official documentation) - if (!this.checkCreateDefaultDir()) return null; - //Check if file with generated name exists - String fullPath = this.getDownloadFilePath(fileName); - //Comment since file picker should do this for us -// File file = new File(fullPath); -// if (file.exists()) { -// Integer nextSuffix = (optionalSuffix != null ? optionalSuffix + 1 : 1); -// return this.getUniqueDownloadFileNameFromDetails(fileDownloadURL, optionalCD, optionalMimeType, nextSuffix); -// } - return new String[]{fullPath, fileName}; + //Start operation + this.pendingInputs.remove(operationID); + if (doNotStart == null || !doNotStart) this.launcher.launch(input); + //Notify + this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.STARTED, null); + return; } } diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java index d7aa6053f..2c28addd2 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java @@ -48,7 +48,7 @@ public static class Operation { public String operationID; public PipedOutputStream outStream; public PipedInputStream inStream; - //state + //state public Boolean closed; public Boolean started; public Boolean pendingClose; @@ -82,11 +82,11 @@ public DownloadJSOperationController(AppCompatActivity activity) { } /* Public operations */ - public void appendToOperation(String operationID, String data) { + public boolean appendToOperation(String operationID, String data) { //get operation status Operation operation = this.operations.get(operationID); if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; - if (operation == null || operation.closed) return; //already closed? + if (operation == null || operation.closed) return false; //already closed? //write try { operation.outStream.write(data.getBytes(StandardCharsets.ISO_8859_1)); @@ -95,23 +95,28 @@ public void appendToOperation(String operationID, String data) { //Ask for close operation.pendingClose = true; } + return !operation.pendingClose; } - public void failOperation(String operationID) { + public boolean failOperation(String operationID) { //get operation status Operation operation = this.operations.get(operationID); if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; - if (operation == null || operation.closed) return; //already closed? + if (operation == null || operation.closed) return false; //already closed? //Ask for close operation.failureClose = true; operation.pendingClose = true; + // + return true; } - public void completeOperation(String operationID) { + public boolean completeOperation(String operationID) { //get operation status Operation operation = this.operations.get(operationID); if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; - if (operation == null || operation.closed) return; //already closed? + if (operation == null || operation.closed) return false; //already closed? //Ask for close operation.pendingClose = true; + // + return true; } @@ -152,12 +157,7 @@ public Boolean parseResult(int resultCode, @Nullable Intent result) { //Thread operation that uses duplex stream private void createThreadedPipeForOperation(Operation operation, Uri uri) { DownloadJSOperationController upperRef = this; - Executors.newSingleThreadExecutor().execute(new Runnable() { - @Override - public void run() { - upperRef.createPipeForOperation(operation, uri); - } - }); + Executors.newSingleThreadExecutor().execute(() -> upperRef.createPipeForOperation(operation, uri)); } private void createPipeForOperation(Operation operation, Uri uri) { //check for operation finished @@ -232,11 +232,9 @@ private void performMediaScan(Uri uri) { // immediately available to the user. MediaScannerConnection.scanFile(this.activity, new String[] { uri.toString() }, null, - new MediaScannerConnection.OnScanCompletedListener() { - public void onScanCompleted(String path, Uri uri2) { - Logger.debug("ExternalStorage", "Scanned " + path + ":"); - Logger.debug("ExternalStorage", "-> uri=" + uri2); - } + (path, uri2) -> { +// Logger.debug("ExternalStorage", "Scanned " + path + ":"); +// Logger.debug("ExternalStorage", "-> uri=" + uri2); }); } diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java index 8795f36b9..2661fce9c 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java @@ -15,7 +15,7 @@ public class DownloadJSProxy implements android.webkit.DownloadListener { final private DownloadJSInterface downloadInterface; public DownloadJSProxy(Bridge bridge) { this.bridge = bridge; - this.downloadInterface = new DownloadJSInterface(this.bridge.getActivity()); + this.downloadInterface = new DownloadJSInterface(this.bridge); this.installServiceWorkerProxy(); } From 93efbff0513108c449fa94beb582879aba825de6 Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Fri, 11 Mar 2022 15:46:29 -0800 Subject: [PATCH 09/14] Improve handler to also download common mime type blobs --- .../Capacitor/WebViewDelegationHandler.swift | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index e87f4d059..879e6ca34 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -166,9 +166,12 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat // Make sure we do handle file downloads if webview can display it public func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { //Check if webview can properly display the file - if navigationResponse.canShowMIMEType { - decisionHandler(.allow) - return + if (navigationResponse.canShowMIMEType) { + let isBlob = navigationResponse.response.url?.absoluteString.starts(with: "blob:") ?? false; + guard #available(iOS 14.5, *), isBlob else { + decisionHandler(.allow) + return + } } //Download support for iOS >= 14.5 if #available(iOS 14.5, *) { @@ -397,21 +400,22 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat CAPLog.print("\n⚡️ See above for help with debugging blank-screen issues") } - private func getUniqueDownloadFileURL(_ suggestedFilename: String, optionalSuffix: String?) -> URL { + private func getUniqueDownloadFileURL(_ suggestedFilename: String, optionalSuffix: Int?) -> URL { let documentsFolderURL = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) // - var fileComps = suggestedFilename.split(separator: "."); + var fileComps = suggestedFilename.split(separator: ".") var fileName = ""; + let suffix = optionalSuffix != nil ? String(optionalSuffix!) : "" if (fileComps.count > 1) { let fileExtension = "." + String(fileComps.popLast() ?? "") - fileName = fileComps.joined(separator: ".") + (optionalSuffix ?? "") + fileExtension + fileName = fileComps.joined(separator: ".") + suffix + fileExtension } else { - fileName = suggestedFilename + (optionalSuffix ?? "") + fileName = suggestedFilename + suffix } //Check if file with generated name exists let documentURL = documentsFolderURL.appendingPathComponent(fileName, isDirectory: false) if fileName == "" || FileManager.default.fileExists(atPath: documentURL.path) { - let randSuffix = String((0..<35).map{ _ in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()! }) + let randSuffix = optionalSuffix != nil ? optionalSuffix! + 1 : 1; return self.getUniqueDownloadFileURL(suggestedFilename, optionalSuffix: randSuffix) } return documentURL From b9ffb6d624761894257c78c407d26132f2dfb4e2 Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Fri, 11 Mar 2022 17:04:26 -0800 Subject: [PATCH 10/14] Add folder picker for iOS --- .../Capacitor/WebViewDelegationHandler.swift | 74 ++++++++++++++----- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index 879e6ca34..a11a26749 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -1,10 +1,11 @@ import Foundation import WebKit +import MobileCoreServices // adopting a public protocol in an internal class is by design // swiftlint:disable lower_acl_than_parent @objc(CAPWebViewDelegationHandler) -open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, UIScrollViewDelegate { +open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, UIScrollViewDelegate, WKDownloadDelegate, UIDocumentPickerDelegate { public internal(set) weak var bridge: CapacitorBridge? open fileprivate(set) var contentController = WKUserContentController() enum WebViewLoadingState { @@ -17,6 +18,13 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat private let handlerName = "bridge" + struct PendingDownload { + let pathSelectionCallback: ((URL?) -> Void) + let proposedFileName: String + let downloadId: Int + } + private var pendingDownload: PendingDownload? + override public init() { super.init() contentController.add(self, name: handlerName) @@ -167,7 +175,7 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat public func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { //Check if webview can properly display the file if (navigationResponse.canShowMIMEType) { - let isBlob = navigationResponse.response.url?.absoluteString.starts(with: "blob:") ?? false; + let isBlob = navigationResponse.response.url?.absoluteString.starts(with: "blob:") ?? false guard #available(iOS 14.5, *), isBlob else { decisionHandler(.allow) return @@ -342,22 +350,22 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat @available(iOS 14.5, *) public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { - // generate unique URL (user can download file with same names or filename not be available on the headers) - let documentURL = self.getUniqueDownloadFileURL(suggestedFilename, optionalSuffix: nil) - CAPLog.print("⚡️ Download path:", documentURL.absoluteString) - completionHandler(documentURL) - // notify - NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ - "id": download.hash, - "status": FileDownloadNotificationStatus.started - ]) + //Add pending download + self.pendingDownload = PendingDownload(pathSelectionCallback: completionHandler, + proposedFileName: suggestedFilename, + downloadId: download.hash) + + // Ask for document selection (it will cal the completion handler) + let documentPicker = UIDocumentPickerViewController(documentTypes: [String(kUTTypeFolder)], in: .open) + documentPicker.delegate = self + bridge?.viewController?.present(documentPicker, animated: true) } @available(iOS 14.5, *) public func downloadDidFinish(_ download: WKDownload) { CAPLog.print("⚡️ Download finished") // notify NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ - "id": download.hash, + "id": String(download.hash), "status": FileDownloadNotificationStatus.completed ]) } @@ -367,11 +375,39 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat CAPLog.print("⚡️ Error: " + error.localizedDescription) // notify NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ - "id": download.hash, + "id": String(download.hash), "error": error.localizedDescription, "status": FileDownloadNotificationStatus.failed ]) } + + // MARK: - UIDocumentPickerDelegate + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + guard self.pendingDownload == nil else { + //cancel download + self.pendingDownload?.pathSelectionCallback(nil) + //empty refs + self.pendingDownload = nil + return + } + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { + guard self.pendingDownload == nil else { + //Generate unique file name on the choosen directory + let fileName: URL = self.getUniqueDownloadFileURL(url, suggestedFilename: self.pendingDownload!.proposedFileName, optionalSuffix: nil) + self.pendingDownload!.pathSelectionCallback(fileName) + // Notify + NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ + "id": String(self.pendingDownload!.downloadId), + "status": FileDownloadNotificationStatus.started + ]) + //empty refs + self.pendingDownload = nil + return + } + } // MARK: - UIScrollViewDelegate @@ -400,12 +436,10 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat CAPLog.print("\n⚡️ See above for help with debugging blank-screen issues") } - private func getUniqueDownloadFileURL(_ suggestedFilename: String, optionalSuffix: Int?) -> URL { - let documentsFolderURL = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - // - var fileComps = suggestedFilename.split(separator: ".") - var fileName = ""; + private func getUniqueDownloadFileURL(_ documentsFolderURL: URL, suggestedFilename: String, optionalSuffix: Int?) -> URL { let suffix = optionalSuffix != nil ? String(optionalSuffix!) : "" + var fileComps = suggestedFilename.split(separator: ".") + var fileName = "" if (fileComps.count > 1) { let fileExtension = "." + String(fileComps.popLast() ?? "") fileName = fileComps.joined(separator: ".") + suffix + fileExtension @@ -415,8 +449,8 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat //Check if file with generated name exists let documentURL = documentsFolderURL.appendingPathComponent(fileName, isDirectory: false) if fileName == "" || FileManager.default.fileExists(atPath: documentURL.path) { - let randSuffix = optionalSuffix != nil ? optionalSuffix! + 1 : 1; - return self.getUniqueDownloadFileURL(suggestedFilename, optionalSuffix: randSuffix) + let randSuffix = optionalSuffix != nil ? optionalSuffix! + 1 : 1 + return self.getUniqueDownloadFileURL(documentsFolderURL, suggestedFilename: suggestedFilename, optionalSuffix: randSuffix) } return documentURL } From e6afdc169c7feac4a622a94aa6716c5ed0c1a21a Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Thu, 7 Apr 2022 15:30:50 -0700 Subject: [PATCH 11/14] Fix Swiftlint warnings --- .../Capacitor/WebViewDelegationHandler.swift | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index a11a26749..96bdab715 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -5,7 +5,8 @@ import MobileCoreServices // adopting a public protocol in an internal class is by design // swiftlint:disable lower_acl_than_parent @objc(CAPWebViewDelegationHandler) -open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, UIScrollViewDelegate, WKDownloadDelegate, UIDocumentPickerDelegate { +open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, UIScrollViewDelegate, WKDownloadDelegate, + UIDocumentPickerDelegate { public internal(set) weak var bridge: CapacitorBridge? open fileprivate(set) var contentController = WKUserContentController() enum WebViewLoadingState { @@ -55,6 +56,9 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat bridge?.reset() } + // TODO: remove once Xcode 12 support is dropped + #if compiler(>=5.5) + @available(iOS 15, *) open func webView( _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, @@ -64,6 +68,7 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat ) { decisionHandler(.grant) } + #endif open func webView(_ webView: WKWebView, requestDeviceOrientationAndMotionPermissionFor origin: WKSecurityOrigin, @@ -78,7 +83,7 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat // check if we can detect file download on iOS >= 14.5 if #available(iOS 14.5, *) { - if (navigationAction.shouldPerformDownload) { + if navigationAction.shouldPerformDownload { decisionHandler(.download) return } @@ -173,21 +178,21 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat // Make sure we do handle file downloads if webview can display it public func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { - //Check if webview can properly display the file - if (navigationResponse.canShowMIMEType) { + // Check if webview can properly display the file + if navigationResponse.canShowMIMEType { let isBlob = navigationResponse.response.url?.absoluteString.starts(with: "blob:") ?? false guard #available(iOS 14.5, *), isBlob else { decisionHandler(.allow) return } } - //Download support for iOS >= 14.5 + // Download support for iOS >= 14.5 if #available(iOS 14.5, *) { decisionHandler(.download) return } - //Deny if not recognize until now and webView can not - //show the specified MIME type + // Deny if not recognize until now and webView can not + // show the specified MIME type decisionHandler(.cancel) } @@ -348,15 +353,17 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat return nil } + // MARK: - WKDownloadDelegate + @available(iOS 14.5, *) public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { - //Add pending download + // Add pending download self.pendingDownload = PendingDownload(pathSelectionCallback: completionHandler, proposedFileName: suggestedFilename, downloadId: download.hash) - + // Ask for document selection (it will cal the completion handler) - let documentPicker = UIDocumentPickerViewController(documentTypes: [String(kUTTypeFolder)], in: .open) + let documentPicker = UIDocumentPickerViewController(documentTypes: [String(kUTTypeFolder)], in: .open) documentPicker.delegate = self bridge?.viewController?.present(documentPicker, animated: true) } @@ -380,30 +387,30 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat "status": FileDownloadNotificationStatus.failed ]) } - + // MARK: - UIDocumentPickerDelegate - + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { guard self.pendingDownload == nil else { - //cancel download + // cancel download self.pendingDownload?.pathSelectionCallback(nil) - //empty refs + // empty refs self.pendingDownload = nil return } } - + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { - guard self.pendingDownload == nil else { - //Generate unique file name on the choosen directory - let fileName: URL = self.getUniqueDownloadFileURL(url, suggestedFilename: self.pendingDownload!.proposedFileName, optionalSuffix: nil) - self.pendingDownload!.pathSelectionCallback(fileName) + if let pendingDownload = self.pendingDownload { + // Generate unique file name on the choosen directory + if let fileName: URL = self.getUniqueDownloadFileURL(url, suggestedFilename: pendingDownload.proposedFileName, optionalSuffix: nil) { + pendingDownload.pathSelectionCallback(fileName) + } // Notify NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ - "id": String(self.pendingDownload!.downloadId), - "status": FileDownloadNotificationStatus.started + "id": String(pendingDownload.downloadId), "status": FileDownloadNotificationStatus.started ]) - //empty refs + // empty refs self.pendingDownload = nil return } @@ -437,19 +444,21 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat } private func getUniqueDownloadFileURL(_ documentsFolderURL: URL, suggestedFilename: String, optionalSuffix: Int?) -> URL { - let suffix = optionalSuffix != nil ? String(optionalSuffix!) : "" + let suffix = "" + if let optionalSuffix = optionalSuffix { suffix = String(optionalSuffix) } var fileComps = suggestedFilename.split(separator: ".") var fileName = "" - if (fileComps.count > 1) { + if fileComps.count > 1 { let fileExtension = "." + String(fileComps.popLast() ?? "") fileName = fileComps.joined(separator: ".") + suffix + fileExtension } else { fileName = suggestedFilename + suffix } - //Check if file with generated name exists + // Check if file with generated name exists let documentURL = documentsFolderURL.appendingPathComponent(fileName, isDirectory: false) if fileName == "" || FileManager.default.fileExists(atPath: documentURL.path) { - let randSuffix = optionalSuffix != nil ? optionalSuffix! + 1 : 1 + let randSuffix = 1 + if let optionalSuffix = optionalSuffix { randSuffix = optionalSuffix + 1; } return self.getUniqueDownloadFileURL(documentsFolderURL, suggestedFilename: suggestedFilename, optionalSuffix: randSuffix) } return documentURL From efe51b6ac65315dd98865dd64683c403d6796db6 Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Thu, 7 Apr 2022 14:50:24 -0700 Subject: [PATCH 12/14] Fix Java ESlint --- .../src/main/java/com/getcapacitor/App.java | 6 +- .../main/java/com/getcapacitor/Bridge.java | 1 - .../com/getcapacitor/DownloadJSInterface.java | 142 +++++++++++------- .../DownloadJSOperationController.java | 70 +++++---- .../com/getcapacitor/DownloadJSProxy.java | 32 ++-- 5 files changed, 154 insertions(+), 97 deletions(-) diff --git a/android/capacitor/src/main/java/com/getcapacitor/App.java b/android/capacitor/src/main/java/com/getcapacitor/App.java index 3b3713b5c..b297996f1 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/App.java +++ b/android/capacitor/src/main/java/com/getcapacitor/App.java @@ -18,7 +18,11 @@ public interface AppRestoredListener { void onAppRestored(PluginResult result); } - public enum DownloadStatus { STARTED, COMPLETED, FAILED } + public enum DownloadStatus { + STARTED, + COMPLETED, + FAILED + } /** * Interface for callbacks when app is receives download request from webview. diff --git a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java index 34209371b..99be0d91b 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java +++ b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java @@ -589,7 +589,6 @@ public void reset() { private void initWebView() { WebSettings settings = webView.getSettings(); settings.setJavaScriptEnabled(true); - settings.setAllowFileAccess(true); webView.addJavascriptInterface(this.downloadProxy.jsInterface(), this.downloadProxy.jsInterfaceName()); webView.setDownloadListener(this.downloadProxy); settings.setDomStorageEnabled(true); diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java index 56a71f416..bacc1e080 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java @@ -1,10 +1,8 @@ package com.getcapacitor; import android.webkit.JavascriptInterface; - import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.Nullable; - import java.util.HashMap; import java.util.UUID; @@ -15,17 +13,24 @@ * to the proxy in order to have that code executed exclusively for that request. */ public class DownloadJSInterface { - final private DownloadJSOperationController operationsController; - final private ActivityResultLauncher launcher; - final private HashMap pendingInputs; - final private Bridge bridge; + + private final DownloadJSOperationController operationsController; + private final ActivityResultLauncher launcher; + private final HashMap pendingInputs; + private final Bridge bridge; + // public DownloadJSInterface(Bridge bridge) { this.operationsController = new DownloadJSOperationController(bridge.getActivity()); this.pendingInputs = new HashMap<>(); this.bridge = bridge; - this.launcher = bridge.getActivity().registerForActivityResult(this.operationsController, - result -> Logger.debug("DownloadJSActivity result", String.valueOf(result))); + this.launcher = + bridge + .getActivity() + .registerForActivityResult( + this.operationsController, + result -> Logger.debug("DownloadJSActivity result", String.valueOf(result)) + ); } /* JavascriptInterface imp. */ @@ -34,6 +39,7 @@ public void receiveContentTypeFromJavascript(String contentType, String operatio //Transition pending input operation to started with resolved content type this.transitionPendingInputOperation(operationID, contentType, null); } + @JavascriptInterface public void receiveStreamChunkFromJavascript(String chunk, String operationID) { //Guarantee pending input transition to started operation (when no content type is resolved) @@ -41,6 +47,7 @@ public void receiveStreamChunkFromJavascript(String chunk, String operationID) { //Append data to operation this.operationsController.appendToOperation(operationID, chunk); } + @JavascriptInterface public void receiveStreamErrorFromJavascript(String error, String operationID) { //Guarantee pending input transition to 'started-but-stale' operation before actually failing @@ -50,6 +57,7 @@ public void receiveStreamErrorFromJavascript(String error, String operationID) { //Notify this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.FAILED, error); } + @JavascriptInterface public void receiveStreamCompletionFromJavascript(String operationID) { //Complete operation signal @@ -70,64 +78,82 @@ public String getJavascriptBridgeForURL(String fileURL, String contentDispositio //will wait either stream start on content-type resolution to start asking //for file pick and stream drain String operationID = UUID.randomUUID().toString(); - DownloadJSOperationController.Input input = new DownloadJSOperationController.Input(operationID, fileURL, mimeType, contentDisposition); + DownloadJSOperationController.Input input = new DownloadJSOperationController.Input( + operationID, + fileURL, + mimeType, + contentDisposition + ); this.pendingInputs.put(operationID, input); //Return JS bridge with operationID tagged return this.getJavascriptInterfaceBridgeForReadyAvailableData(fileURL, mimeType, operationID); } return null; } + /* Injectors */ private String getJavascriptInterfaceBridgeForReadyAvailableData(String blobUrl, String mimeType, String operationID) { - return "javascript: " + - "" + - "function parseFile(file, chunkReadCallback, errorCallback, successCallback) {\n" + - " let fileSize = file.size;" + - " let chunkSize = 64 * 1024;" + - " let offset = 0;" + - " let self = this;" + - " let readBlock = null;" + - " let onLoadHandler = function(evt) {" + - " if (evt.target.error == null) {" + - " offset += evt.target.result.length;" + - " chunkReadCallback(evt.target.result);" + - " } else {" + - " errorCallback(evt.target.error);" + - " return;" + - " }" + - " if (offset >= fileSize) {" + - " if (successCallback) successCallback();" + - " return;" + - " }" + - " readBlock(offset, chunkSize, file);" + - " };" + - " readBlock = function(_offset, length, _file) {" + - " var r = new FileReader();" + - " var blob = _file.slice(_offset, length + _offset);" + - " r.onload = onLoadHandler;" + - " r.readAsBinaryString(blob);" + - " };" + - " readBlock(offset, chunkSize, file);" + - "};\n" + - "(() => { let xhr = new XMLHttpRequest();" + - "xhr.open('GET', '"+ blobUrl +"', true);" + - ((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Content-type','" + mimeType + "');" : "") + - "xhr.responseType = 'blob';" + - "xhr.onerror = xhr.onload = function(e) {" + - " if (this.status == 200) {" + - " let contentType = this.getResponseHeader('content-type');" + - " if (contentType) { CapacitorDownloadInterface.receiveContentTypeFromJavascript(contentType, '" + operationID + "'); }" + - " var blob = this.response;" + - " parseFile(blob, " + - " function(chunk) { CapacitorDownloadInterface.receiveStreamChunkFromJavascript(chunk, '" + operationID + "'); }," + - " function(err) { console.error('[Capacitor XHR] - error:', err); CapacitorDownloadInterface.receiveStreamChunkFromJavascript(err.message, '" + operationID + "'); }, " + - " function() { console.log('[Capacitor XHR] - Drained!'); CapacitorDownloadInterface.receiveStreamCompletionFromJavascript('" + operationID + "'); } " + - " );" + - " } else {" + - " console.error('[Capacitor XHR] - error:', this.status, (e ? e.loaded : this.responseText));" + - " }" + - "};" + - "xhr.send();})()"; + return ( + "javascript: " + + "" + + "function parseFile(file, chunkReadCallback, errorCallback, successCallback) {\n" + + " let fileSize = file.size;" + + " let chunkSize = 64 * 1024;" + + " let offset = 0;" + + " let self = this;" + + " let readBlock = null;" + + " let onLoadHandler = function(evt) {" + + " if (evt.target.error == null) {" + + " offset += evt.target.result.length;" + + " chunkReadCallback(evt.target.result);" + + " } else {" + + " errorCallback(evt.target.error);" + + " return;" + + " }" + + " if (offset >= fileSize) {" + + " if (successCallback) successCallback();" + + " return;" + + " }" + + " readBlock(offset, chunkSize, file);" + + " };" + + " readBlock = function(_offset, length, _file) {" + + " var r = new FileReader();" + + " var blob = _file.slice(_offset, length + _offset);" + + " r.onload = onLoadHandler;" + + " r.readAsBinaryString(blob);" + + " };" + + " readBlock(offset, chunkSize, file);" + + "};\n" + + "(() => { let xhr = new XMLHttpRequest();" + + "xhr.open('GET', '" + + blobUrl + + "', true);" + + ((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Content-type','" + mimeType + "');" : "") + + "xhr.responseType = 'blob';" + + "xhr.onerror = xhr.onload = function(e) {" + + " if (this.status == 200) {" + + " let contentType = this.getResponseHeader('content-type');" + + " if (contentType) { CapacitorDownloadInterface.receiveContentTypeFromJavascript(contentType, '" + + operationID + + "'); }" + + " var blob = this.response;" + + " parseFile(blob, " + + " function(chunk) { CapacitorDownloadInterface.receiveStreamChunkFromJavascript(chunk, '" + + operationID + + "'); }," + + " function(err) { console.error('[Capacitor XHR] - error:', err); CapacitorDownloadInterface.receiveStreamChunkFromJavascript(err.message, '" + + operationID + + "'); }, " + + " function() { console.log('[Capacitor XHR] - Drained!'); CapacitorDownloadInterface.receiveStreamCompletionFromJavascript('" + + operationID + + "'); } " + + " );" + + " } else {" + + " console.error('[Capacitor XHR] - error:', this.status, (e ? e.loaded : this.responseText));" + + " }" + + "};" + + "xhr.send();})()" + ); } /* Helpers */ diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java index 2c28addd2..7c93152cb 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java @@ -1,8 +1,8 @@ package com.getcapacitor; +import android.app.Activity; import android.content.Context; import android.content.Intent; -import android.app.Activity; import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; @@ -10,12 +10,10 @@ import android.provider.DocumentsContract; import android.text.TextUtils; import android.webkit.URLUtil; - import androidx.activity.result.contract.ActivityResultContract; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; - import java.io.File; import java.io.IOException; import java.io.OutputStream; @@ -29,12 +27,15 @@ import java.util.concurrent.Executors; public class DownloadJSOperationController extends ActivityResultContract { + /* DownloadJSActivity Input */ public static class Input { + public String fileNameURL; public String optionalMimeType; public String contentDisposition; public String operationID; + public Input(String operationID, String fileNameURL, String optionalMimeType, String contentDisposition) { this.operationID = operationID; this.fileNameURL = fileNameURL; @@ -42,9 +43,11 @@ public Input(String operationID, String fileNameURL, String optionalMimeType, St this.contentDisposition = contentDisposition; } } + /* DownloadJSActivity internal operation */ public static class Operation { - final private Input input; + + private final Input input; public String operationID; public PipedOutputStream outStream; public PipedInputStream inStream; @@ -53,6 +56,7 @@ public static class Operation { public Boolean started; public Boolean pendingClose; public Boolean failureClose; + // public Operation(Input input) { this.input = input; @@ -71,10 +75,11 @@ public Operation(Input input) { } /* DownloadJSActivity */ - final private static String EXTRA_OPERATION_ID = "OPERATION_ID"; - final private AppCompatActivity activity; - final private HashMap operations; + private static final String EXTRA_OPERATION_ID = "OPERATION_ID"; + private final AppCompatActivity activity; + private final HashMap operations; private Operation pendingOperation; + // public DownloadJSOperationController(AppCompatActivity activity) { this.activity = activity; @@ -97,6 +102,7 @@ public boolean appendToOperation(String operationID, String data) { } return !operation.pendingClose; } + public boolean failOperation(String operationID) { //get operation status Operation operation = this.operations.get(operationID); @@ -108,6 +114,7 @@ public boolean failOperation(String operationID) { // return true; } + public boolean completeOperation(String operationID) { //get operation status Operation operation = this.operations.get(operationID); @@ -119,24 +126,28 @@ public boolean completeOperation(String operationID) { return true; } - /* ActivityResultContract Implementation */ @NonNull public Intent createIntent(@NonNull Context context, DownloadJSOperationController.Input input) { //ask path - String[] paths = this.getUniqueDownloadFileNameFromDetails(input.fileNameURL, input.contentDisposition, input.optionalMimeType, null); + String[] paths = + this.getUniqueDownloadFileNameFromDetails(input.fileNameURL, input.contentDisposition, input.optionalMimeType, null); //Create/config intent to prompt for file selection Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); if (paths != null && paths[1] != null) intent.putExtra(Intent.EXTRA_TITLE, paths[1]); intent.putExtra(EXTRA_OPERATION_ID, input.operationID); if (input.optionalMimeType != null) intent.setType(input.optionalMimeType); - if (paths != null && paths[0] != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, paths[0]); + if (paths != null && paths[0] != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) intent.putExtra( + DocumentsContract.EXTRA_INITIAL_URI, + paths[0] + ); //Add operation this.pendingOperation = new Operation(input); // return intent; } + public Boolean parseResult(int resultCode, @Nullable Intent result) { //get operation status Operation operation = this.pendingOperation; @@ -159,6 +170,7 @@ private void createThreadedPipeForOperation(Operation operation, Uri uri) { DownloadJSOperationController upperRef = this; Executors.newSingleThreadExecutor().execute(() -> upperRef.createPipeForOperation(operation, uri)); } + private void createPipeForOperation(Operation operation, Uri uri) { //check for operation finished if (operation.started || operation.closed) return; @@ -213,8 +225,9 @@ private void cancelPreOperation(Operation operation) { try { operation.outStream.close(); operation.inStream.close(); - } catch (IOException ignored) { } //failsafe stream close + } catch (IOException ignored) {} //failsafe stream close } + private void releaseOperation(String operationID) { //get operation status Operation operation = this.operations.get(operationID); @@ -230,18 +243,22 @@ private void releaseOperation(String operationID) { private void performMediaScan(Uri uri) { // Tell the media scanner about the new file so that it is // immediately available to the user. - MediaScannerConnection.scanFile(this.activity, - new String[] { uri.toString() }, null, - (path, uri2) -> { -// Logger.debug("ExternalStorage", "Scanned " + path + ":"); -// Logger.debug("ExternalStorage", "-> uri=" + uri2); - }); + MediaScannerConnection.scanFile( + this.activity, + new String[] { uri.toString() }, + null, + (path, uri2) -> { + // Logger.debug("ExternalStorage", "Scanned " + path + ":"); + // Logger.debug("ExternalStorage", "-> uri=" + uri2); + } + ); } /* FS Utils */ private String getDownloadFilePath(String fileName) { return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() + '/' + fileName; } + private boolean checkCreateDefaultDir() { boolean created = false; try { @@ -254,11 +271,17 @@ private boolean checkCreateDefaultDir() { } return created; } - private String[] getUniqueDownloadFileNameFromDetails(String fileDownloadURL, String optionalCD, String optionalMimeType, @Nullable Integer optionalSuffix) { + + private String[] getUniqueDownloadFileNameFromDetails( + String fileDownloadURL, + String optionalCD, + String optionalMimeType, + @Nullable Integer optionalSuffix + ) { //Auxs for filename gen. String suggestedFilename = URLUtil.guessFileName(fileDownloadURL, optionalCD, optionalMimeType); ArrayList fileComps = new ArrayList<>(Arrays.asList(suggestedFilename.split("."))); - String suffix = (optionalSuffix != null ? " (" + optionalSuffix + ")" : ""); + String suffix = (optionalSuffix != null ? " (" + optionalSuffix + ")" : ""); //Check for invalid filename if (suggestedFilename.length() <= 0) suggestedFilename = UUID.randomUUID().toString(); //Generate filename @@ -273,12 +296,7 @@ private String[] getUniqueDownloadFileNameFromDetails(String fileDownloadURL, St if (!this.checkCreateDefaultDir()) return null; //Check if file with generated name exists String fullPath = this.getDownloadFilePath(fileName); - //Comment since file picker should do this for us -// File file = new File(fullPath); -// if (file.exists()) { -// Integer nextSuffix = (optionalSuffix != null ? optionalSuffix + 1 : 1); -// return this.getUniqueDownloadFileNameFromDetails(fileDownloadURL, optionalCD, optionalMimeType, nextSuffix); -// } - return new String[]{fullPath, fileName}; + // + return new String[] { fullPath, fileName }; } } diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java index 2661fce9c..85caa0278 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java @@ -11,8 +11,10 @@ * dynamic javascript upon the 'protocol' interface availability. */ public class DownloadJSProxy implements android.webkit.DownloadListener { - final private Bridge bridge; - final private DownloadJSInterface downloadInterface; + + private final Bridge bridge; + private final DownloadJSInterface downloadInterface; + public DownloadJSProxy(Bridge bridge) { this.bridge = bridge; this.downloadInterface = new DownloadJSInterface(this.bridge); @@ -20,8 +22,13 @@ public DownloadJSProxy(Bridge bridge) { } // - public DownloadJSInterface jsInterface() { return this.downloadInterface; } - public String jsInterfaceName() { return "CapacitorDownloadInterface"; } + public DownloadJSInterface jsInterface() { + return this.downloadInterface; + } + + public String jsInterfaceName() { + return "CapacitorDownloadInterface"; + } /* Public interceptors */ public boolean shouldOverrideLoad(String url) { @@ -40,6 +47,7 @@ public boolean shouldOverrideLoad(String url) { return false; } } + /* Public DownloadListener implementation */ @Override public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimeType, long contentLength) { @@ -60,13 +68,15 @@ private void installServiceWorkerProxy() { //Downloads can be done via webworker, webworkers might need local resources, we enable that if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { ServiceWorkerController swController = ServiceWorkerController.getInstance(); - swController.setServiceWorkerClient(new ServiceWorkerClient() { - @Override - public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) { - Logger.debug("ServiceWorker Request", request.getUrl().toString()); - return bridge.getLocalServer().shouldInterceptRequest(request); + swController.setServiceWorkerClient( + new ServiceWorkerClient() { + @Override + public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) { + Logger.debug("ServiceWorker Request", request.getUrl().toString()); + return bridge.getLocalServer().shouldInterceptRequest(request); + } } - }); + ); } } -} \ No newline at end of file +} From a6ba8b9b999f3d37e672c5a67a539d6d445ccecc Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Fri, 8 Apr 2022 09:39:59 -0700 Subject: [PATCH 13/14] Fix compiler errors after fixes on swift lint --- ios/Capacitor/Capacitor/WebViewDelegationHandler.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index 96bdab715..e718afc30 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -403,9 +403,8 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { if let pendingDownload = self.pendingDownload { // Generate unique file name on the choosen directory - if let fileName: URL = self.getUniqueDownloadFileURL(url, suggestedFilename: pendingDownload.proposedFileName, optionalSuffix: nil) { - pendingDownload.pathSelectionCallback(fileName) - } + let fileName: URL = self.getUniqueDownloadFileURL(url, suggestedFilename: pendingDownload.proposedFileName, optionalSuffix: nil) + pendingDownload.pathSelectionCallback(fileName) // Notify NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ "id": String(pendingDownload.downloadId), "status": FileDownloadNotificationStatus.started @@ -444,7 +443,7 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat } private func getUniqueDownloadFileURL(_ documentsFolderURL: URL, suggestedFilename: String, optionalSuffix: Int?) -> URL { - let suffix = "" + var suffix = "" if let optionalSuffix = optionalSuffix { suffix = String(optionalSuffix) } var fileComps = suggestedFilename.split(separator: ".") var fileName = "" @@ -457,7 +456,7 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat // Check if file with generated name exists let documentURL = documentsFolderURL.appendingPathComponent(fileName, isDirectory: false) if fileName == "" || FileManager.default.fileExists(atPath: documentURL.path) { - let randSuffix = 1 + var randSuffix = 1 if let optionalSuffix = optionalSuffix { randSuffix = optionalSuffix + 1; } return self.getUniqueDownloadFileURL(documentsFolderURL, suggestedFilename: suggestedFilename, optionalSuffix: randSuffix) } From c4e53ad134467abf2439ea4b9376471f2f020f07 Mon Sep 17 00:00:00 2001 From: Gabriel Debes Date: Fri, 8 Apr 2022 10:54:08 -0700 Subject: [PATCH 14/14] Fix compile errors on Xcode 12.4 and bellow --- .../Capacitor/WebViewDelegationHandler.swift | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index e718afc30..ac8316d0b 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -2,6 +2,11 @@ import Foundation import WebKit import MobileCoreServices +// TODO: remove once Xcode 12 support is dropped +#if compiler(<5.5) + protocol WKDownloadDelegate {} +#endif + // adopting a public protocol in an internal class is by design // swiftlint:disable lower_acl_than_parent @objc(CAPWebViewDelegationHandler) @@ -81,13 +86,16 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat // post a notification for any listeners NotificationCenter.default.post(name: .capacitorDecidePolicyForNavigationAction, object: navigationAction) - // check if we can detect file download on iOS >= 14.5 - if #available(iOS 14.5, *) { - if navigationAction.shouldPerformDownload { - decisionHandler(.download) - return + // TODO: remove once Xcode 12 support is dropped + #if compiler(>=5.5) + // check if we can detect file download on iOS >= 14.5 + if #available(iOS 14.5, *) { + if navigationAction.shouldPerformDownload { + decisionHandler(.download) + return + } } - } + #endif // sanity check, these shouldn't ever be nil in practice guard let bridge = bridge, let navURL = navigationAction.request.url else { @@ -186,11 +194,14 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat return } } - // Download support for iOS >= 14.5 - if #available(iOS 14.5, *) { - decisionHandler(.download) - return - } + // TODO: remove once Xcode 12 support is dropped + #if compiler(>=5.5) + // Download support for iOS >= 14.5 + if #available(iOS 14.5, *) { + decisionHandler(.download) + return + } + #endif // Deny if not recognize until now and webView can not // show the specified MIME type decisionHandler(.cancel) @@ -202,6 +213,22 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat webView.reload() } + // TODO: remove once Xcode 12 support is dropped + #if compiler(>=5.5) + + @available(iOS 14.5, *) + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + CAPLog.print("⚡️ Initiating background download..") + download.delegate = self + } + @available(iOS 14.5, *) + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + CAPLog.print("⚡️ Initiating background download..") + download.delegate = self + } + + #endif + // MARK: - WKScriptMessageHandler open func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { @@ -354,7 +381,8 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat } // MARK: - WKDownloadDelegate - + // TODO: remove once Xcode 12 support is dropped + #if compiler(>=5.5) @available(iOS 14.5, *) public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { // Add pending download @@ -387,6 +415,7 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat "status": FileDownloadNotificationStatus.failed ]) } + #endif // MARK: - UIDocumentPickerDelegate