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 d4995fbbc..f94eab205 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..66b213a9e --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java @@ -0,0 +1,184 @@ +package com.getcapacitor; + +import android.app.Activity; +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) { + this.transitionPendingInputOperation(operationID, contentType, false); + } + + @JavascriptInterface + public void receiveStreamChunkFromJavascript(String chunk, String operationID) { + this.transitionPendingInputOperation(operationID, null, false); + this.operationsController.appendToOperation(operationID, chunk); + } + + @JavascriptInterface + public void receiveStreamErrorFromJavascript(String error, String operationID) { + // Drop pending input without launching the file picker or emitting STARTED. + this.pendingInputs.remove(operationID); + this.operationsController.failOperation(operationID); + this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.FAILED, error); + } + + @JavascriptInterface + public void receiveStreamCompletionFromJavascript(String operationID) { + if (!this.operationsController.completeOperation(operationID)) return; + 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:")) { + String operationID = UUID.randomUUID().toString(); + DownloadJSOperationController.Input input = new DownloadJSOperationController.Input( + operationID, + fileURL, + mimeType, + contentDisposition + ); + this.pendingInputs.put(operationID, input); + return this.getJavascriptInterfaceBridgeForReadyAvailableData(fileURL, mimeType, operationID); + } + return null; + } + + /* Injectors */ + private String getJavascriptInterfaceBridgeForReadyAvailableData(String blobUrl, String mimeType, String operationID) { + String escapedUrl = blobUrl.replace("\\", "\\\\").replace("'", "\\'"); + String acceptHeader = + (mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Accept','" + mimeType.replace("'", "") + "');" : ""; + return ( + "javascript: " + + "function parseFile(file, chunkReadCallback, errorCallback, successCallback) {\n" + + " let fileSize = file.size;" + + " let chunkSize = 64 * 1024;" + + " let offset = 0;" + + " let readBlock = null;" + + " let onLoadHandler = function(evt) {" + + " if (evt.target.error == null) {" + + " var buf = evt.target.result;" + + " var bytes = new Uint8Array(buf);" + + " offset += bytes.length;" + + " var binary = '';" + + " for (var i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); }" + + " chunkReadCallback(binary);" + + " } 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', '" + + escapedUrl + + "', true);" + + acceptHeader + + "xhr.responseType = 'blob';" + + "xhr.onerror = function(e) {" + + " var msg = (e && e.type) ? e.type : 'network error';" + + " console.error('[Capacitor XHR] - error:', msg);" + + " CapacitorDownloadInterface.receiveStreamErrorFromJavascript(msg, '" + + operationID + + "');" + + "};" + + "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.receiveStreamErrorFromJavascript(err && err.message ? err.message : 'Unknown error', '" + + operationID + + "'); }, " + + " function() { console.log('[Capacitor XHR] - Drained!'); CapacitorDownloadInterface.receiveStreamCompletionFromJavascript('" + + operationID + + "'); } " + + " );" + + " } else {" + + " var msg = 'HTTP ' + this.status;" + + " console.error('[Capacitor XHR] - error:', this.status, (e ? e.loaded : this.responseText));" + + " CapacitorDownloadInterface.receiveStreamErrorFromJavascript(msg, '" + + operationID + + "');" + + " }" + + "};" + + "xhr.send();})()" + ); + } + + /* Helpers */ + private void transitionPendingInputOperation(String operationID, @Nullable String optionalContentType, boolean doNotStart) { + DownloadJSOperationController.Input input = this.pendingInputs.get(operationID); + if (input == null) return; + if (optionalContentType != null) { + Logger.debug("Received content type", optionalContentType); + input.optionalMimeType = optionalContentType; + } + this.pendingInputs.remove(operationID); + Activity activity = bridge.getActivity(); + if (activity == null) return; + activity.runOnUiThread( + () -> { + if (!doNotStart) { + launcher.launch(input); + bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.STARTED, null); + } + } + ); + } +} 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..1ec997656 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java @@ -0,0 +1,273 @@ +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.Environment; +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.ExecutorService; +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 final ExecutorService pipeExecutor = Executors.newCachedThreadPool(); + private Operation pendingOperation; + + public DownloadJSOperationController(AppCompatActivity activity) { + this.activity = activity; + this.operations = new HashMap<>(); + } + + /* Public operations */ + public boolean appendToOperation(String operationID, String data) { + Operation operation = resolveOperation(operationID); + if (operation == null || operation.closed) return false; + try { + operation.outStream.write(data.getBytes(StandardCharsets.ISO_8859_1)); + } catch (IOException e) { + Logger.debug("Exception while writing on DownloadJSActivity stream. Closing it!", e.toString()); + operation.pendingClose = true; + } + return !operation.pendingClose; + } + + public boolean failOperation(String operationID) { + Operation operation = resolveOperation(operationID); + if (operation == null || operation.closed) return false; + operation.failureClose = true; + operation.pendingClose = true; + return true; + } + + public boolean completeOperation(String operationID) { + Operation operation = resolveOperation(operationID); + if (operation == null || operation.closed) return false; + operation.pendingClose = true; + return true; + } + + /* ActivityResultContract Implementation */ + @Override + @NonNull + public Intent createIntent(@NonNull Context context, DownloadJSOperationController.Input input) { + String[] paths = + this.getUniqueDownloadFileNameFromDetails(input.fileNameURL, input.contentDisposition, input.optionalMimeType, null); + 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); + // EXTRA_INITIAL_URI expects a document/tree Uri. A filesystem path string is not valid + // here and can crash on some OEMs, so we do not set it. + this.pendingOperation = new Operation(input); + return intent; + } + + @Override + public Boolean parseResult(int resultCode, @Nullable Intent result) { + Operation operation = this.pendingOperation; + if (operation == null) return false; + if (resultCode == Activity.RESULT_OK && result != null) { + Uri uri = result.getData(); + if (uri == null) { + this.pendingOperation = null; + this.cancelPreOperation(operation); + return false; + } + this.operations.put(operation.input.operationID, operation); + this.pendingOperation = null; + this.createThreadedPipeForOperation(operation, uri); + return true; + } + this.pendingOperation = null; + this.cancelPreOperation(operation); + return false; + } + + private void createThreadedPipeForOperation(Operation operation, Uri uri) { + pipeExecutor.execute(() -> createPipeForOperation(operation, uri)); + } + + private void createPipeForOperation(Operation operation, Uri uri) { + if (operation.started || operation.closed) return; + operation.started = true; + try { + OutputStream output = this.activity.getContentResolver().openOutputStream(uri); + if (output == null) { + Logger.debug("Failed to open OutputStream for uri in DownloadJSActivity threaded operation.", uri.toString()); + this.cancelPreOperation(operation); + this.releaseOperation(operation.input.operationID); + return; + } + byte[] buffer = new byte[64 * 1024]; + int lastReadSize; + while (true) { + lastReadSize = operation.inStream.read(buffer, 0, buffer.length); + if (lastReadSize == -1) { + break; + } + if (lastReadSize == 0) { + if (operation.pendingClose) { + break; + } + continue; + } + output.write(buffer, 0, lastReadSize); + if (operation.pendingClose && operation.inStream.available() <= 0) { + break; + } + } + output.flush(); + output.close(); + operation.closed = true; + operation.outStream.close(); + operation.inStream.close(); + this.releaseOperation(operation.input.operationID); + this.performMediaScan(uri); + } catch (Exception e) { + Logger.debug("Exception while running DownloadJSActivity threaded operation.", e.toString()); + this.cancelPreOperation(operation); + this.releaseOperation(operation.input.operationID); + } + Logger.debug("DownloadJSActivity completed!", operation.input.operationID); + } + + /* Operation Utils */ + private Operation resolveOperation(String operationID) { + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation != null && this.pendingOperation.input.operationID.equals(operationID)) { + operation = this.pendingOperation; + } + return operation; + } + + private void cancelPreOperation(Operation operation) { + operation.pendingClose = true; + operation.closed = true; + try { + operation.outStream.close(); + operation.inStream.close(); + } catch (IOException ignored) {} + } + + private void releaseOperation(String operationID) { + Operation operation = resolveOperation(operationID); + if (operation == null) return; + if (!operation.pendingClose) operation.pendingClose = true; + this.operations.remove(operation.input.operationID); + if (this.pendingOperation != null && this.pendingOperation.input.operationID.equals(operationID)) { + this.pendingOperation = null; + } + } + + /* Media utils */ + private void performMediaScan(Uri uri) { + MediaScannerConnection.scanFile(this.activity, new String[] { uri.toString() }, null, (path, 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 + ) { + String suggestedFilename = URLUtil.guessFileName(fileDownloadURL, optionalCD, optionalMimeType); + ArrayList fileComps = new ArrayList<>(Arrays.asList(suggestedFilename.split("\\."))); + String suffix = (optionalSuffix != null ? " (" + optionalSuffix + ")" : ""); + if (suggestedFilename.length() <= 0) suggestedFilename = UUID.randomUUID().toString(); + String fileName; + if (fileComps.size() > 1) { + String fileExtension = "." + fileComps.remove(fileComps.size() - 1); + fileName = TextUtils.join(".", fileComps) + suffix + fileExtension; + } else { + fileName = suggestedFilename + suffix; + } + if (!this.checkCreateDefaultDir()) return null; + 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..d1733dd7a --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java @@ -0,0 +1,55 @@ +package com.getcapacitor; + +/** + * 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. + * + * Service worker interception is left to {@link Bridge}, which already installs a client + * when {@code config.isResolveServiceWorkerRequests()} is true. + */ +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); + } + + public DownloadJSInterface jsInterface() { + return this.downloadInterface; + } + + public String jsInterfaceName() { + return "CapacitorDownloadInterface"; + } + + /* Public interceptors */ + public boolean shouldOverrideLoad(String url) { + if (!url.startsWith("blob:")) return false; + Logger.debug("Capacitor webview intercepted blob download request", url); + String bridgeJs = this.downloadInterface.getJavascriptBridgeForURL(url, null, null); + if (bridgeJs != null) { + this.bridge.getWebView().loadUrl(bridgeJs); + 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) { + Logger.debug("Capacitor webview download start request", url); + Logger.debug(userAgent + " - " + contentDisposition + " - " + mimeType); + String bridgeJs = this.downloadInterface.getJavascriptBridgeForURL(url, contentDisposition, mimeType); + if (bridgeJs != null) { + this.bridge.getWebView().loadUrl(bridgeJs); + } else { + Logger.info("Capacitor webview download has no handler for the following url", url); + } + } +} diff --git a/ios/Capacitor/Capacitor/CAPNotifications.swift b/ios/Capacitor/Capacitor/CAPNotifications.swift index 9731f74e4..b6657f8f7 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 = @@ -24,10 +25,17 @@ extension Notification.Name { Notification.Name(rawValue: "CapacitorSceneOpenUniversalLinkNotification") } +@objc public enum FileDownloadNotificationStatus: Int { + case started = 0 + case completed = 1 + case failed = 2 +} + @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 866c7e8c5..4a96daa11 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -1,10 +1,22 @@ import Foundation import WebKit +#if canImport(UniformTypeIdentifiers) +import UniformTypeIdentifiers +#endif +#if canImport(MobileCoreServices) +import MobileCoreServices +#endif + +// 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 +29,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 +66,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 +78,7 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat ) { decisionHandler(.grant) } + #endif open func webView(_ webView: WKWebView, requestDeviceOrientationAndMotionPermissionFor origin: WKSecurityOrigin, @@ -68,6 +91,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 +189,50 @@ 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 open func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping @MainActor (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { guard let bridge = bridge else { completionHandler(.rejectProtectionSpace, nil) @@ -332,6 +404,74 @@ 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 call the completion handler) + let documentPicker: UIDocumentPickerViewController + if #available(iOS 14.0, *) { + documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.folder]) + } else { + 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") + NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ + "id": String(download.hash), + "status": FileDownloadNotificationStatus.completed.rawValue + ]) + } + @available(iOS 14.5, *) + public func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + CAPLog.print("⚡️ Download failed") + CAPLog.print("⚡️ Error: " + error.localizedDescription) + NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ + "id": String(download.hash), + "error": error.localizedDescription, + "status": FileDownloadNotificationStatus.failed.rawValue + ]) + } + #endif + + // MARK: - UIDocumentPickerDelegate + + public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + guard self.pendingDownload != nil else { return } + self.pendingDownload?.pathSelectionCallback(nil) + self.pendingDownload = nil + } + + public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + handlePickedDownloadDestination(url) + } + + public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { + handlePickedDownloadDestination(url) + } + + private func handlePickedDownloadDestination(_ url: URL) { + guard let pendingDownload = self.pendingDownload else { return } + let fileName: URL = self.getUniqueDownloadFileURL(url, suggestedFilename: pendingDownload.proposedFileName, optionalSuffix: nil) + pendingDownload.pathSelectionCallback(fileName) + NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ + "id": String(pendingDownload.downloadId), + "status": FileDownloadNotificationStatus.started.rawValue + ]) + self.pendingDownload = nil + } + // MARK: - UIScrollViewDelegate // disable zooming in WKWebView ScrollView @@ -358,4 +498,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 + } }