diff --git a/android/capacitor/src/main/java/com/getcapacitor/App.java b/android/capacitor/src/main/java/com/getcapacitor/App.java index f46b6332b..b297996f1 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/App.java +++ b/android/capacitor/src/main/java/com/getcapacitor/App.java @@ -18,12 +18,28 @@ 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 +62,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 +82,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/Bridge.java b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java index 3b135910c..99be0d91b 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,6 +589,8 @@ public void reset() { private void initWebView() { WebSettings settings = webView.getSettings(); settings.setJavaScriptEnabled(true); + webView.addJavascriptInterface(this.downloadProxy.jsInterface(), this.downloadProxy.jsInterfaceName()); + webView.setDownloadListener(this.downloadProxy); settings.setDomStorageEnabled(true); settings.setGeolocationEnabled(true); settings.setMediaPlaybackRequiresUserGesture(false); 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..bacc1e080 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java @@ -0,0 +1,177 @@ +package com.getcapacitor; + +import android.webkit.JavascriptInterface; +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.Nullable; +import java.util.HashMap; +import java.util.UUID; + +/** + * 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 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)) + ); + } + + /* 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); + } + + /* 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); + } + 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();})()" + ); + } + + /* 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); + //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 new file mode 100644 index 000000000..7c93152cb --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java @@ -0,0 +1,302 @@ +package com.getcapacitor; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +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 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; +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 { + + private final 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 */ + 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; + this.operations = new HashMap<>(); + } + + /* Public operations */ + 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 false; //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; + } + return !operation.pendingClose; + } + + 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 false; //already closed? + //Ask for close + operation.failureClose = true; + operation.pendingClose = true; + // + return true; + } + + 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 false; //already closed? + //Ask for close + operation.pendingClose = true; + // + 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); + //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; + } + + //Thread operation that uses duplex stream + 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; + //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, + (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 { + 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; + } + //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); + // + 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 new file mode 100644 index 000000000..85caa0278 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java @@ -0,0 +1,82 @@ +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 availability. + */ +public class DownloadJSProxy implements android.webkit.DownloadListener { + + private final Bridge bridge; + private final DownloadJSInterface downloadInterface; + + public DownloadJSProxy(Bridge bridge) { + this.bridge = bridge; + this.downloadInterface = new DownloadJSInterface(this.bridge); + this.installServiceWorkerProxy(); + } + + // + 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 + Logger.debug("Capacitor webview download start request", url); + Logger.debug(userAgent + " - " + contentDisposition + " - " + mimeType); + //Check if we can handle the URL.. + String bridge = this.downloadInterface.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 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) { + 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); + } + } + ); + } + } +} 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 d0870ad1e..ac8316d0b 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -1,10 +1,17 @@ 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) -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 +24,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) @@ -47,6 +61,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, @@ -56,6 +73,7 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat ) { decisionHandler(.grant) } + #endif open func webView(_ webView: WKWebView, requestDeviceOrientationAndMotionPermissionFor origin: WKSecurityOrigin, @@ -68,6 +86,17 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat // post a notification for any listeners NotificationCenter.default.post(name: .capacitorDecidePolicyForNavigationAction, object: navigationAction) + // 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 { decisionHandler(.allow) @@ -155,12 +184,51 @@ 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 { + let isBlob = navigationResponse.response.url?.absoluteString.starts(with: "blob:") ?? false + guard #available(iOS 14.5, *), isBlob else { + decisionHandler(.allow) + 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) + } + open func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { CAPLog.print("⚡️ WebView process terminated") bridge?.reset() 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) { @@ -312,6 +380,70 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat return nil } + // 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 + 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": String(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": String(download.hash), + "error": error.localizedDescription, + "status": FileDownloadNotificationStatus.failed + ]) + } + #endif + + // 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) { + if let pendingDownload = self.pendingDownload { + // Generate unique file name on the choosen directory + 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 + ]) + // empty refs + self.pendingDownload = nil + return + } + } + // MARK: - UIScrollViewDelegate // disable zooming in WKWebView ScrollView @@ -338,4 +470,25 @@ 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 getUniqueDownloadFileURL(_ documentsFolderURL: URL, suggestedFilename: String, optionalSuffix: Int?) -> URL { + var suffix = "" + if let optionalSuffix = optionalSuffix { suffix = String(optionalSuffix) } + var fileComps = suggestedFilename.split(separator: ".") + var fileName = "" + 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 + let documentURL = documentsFolderURL.appendingPathComponent(fileName, isDirectory: false) + if fileName == "" || FileManager.default.fileExists(atPath: documentURL.path) { + var randSuffix = 1 + if let optionalSuffix = optionalSuffix { randSuffix = optionalSuffix + 1; } + return self.getUniqueDownloadFileURL(documentsFolderURL, suggestedFilename: suggestedFilename, optionalSuffix: randSuffix) + } + return documentURL + } }