Download files in MAUI iOS WebView

906 Views Asked by At

I have a WebView in a MAUI app that generally works, but whenever I click a link on iOS that is supposed to download a file (link returns a Content-Disposition header) it is opened in the WebView. I would like it to be downloaded (and opened in the default iOS app) instead.

How is this supposed to be implemented? There is apparently a IWKDownloadDelegate interface with a DecideDestination() method but I can't find examples of how to wire it all up in MAUI. I got it working on Android by writing some platform-specific code, and I imagine something similar can be done for iOS.

<WebView
    x:Name="WebView"
    Source=".." />
public partial class WebClientPage : ContentPage
{
    public WebClientPage()
    {
        InitializeComponent();
    }

    protected override void OnHandlerChanged()
    {
        base.OnHandlerChanged();

#if IOS
        var iosWebView = WebView.Handler.PlatformView as WebKit.WKWebView;

        // Otherwise swiping doesn't work
        iosWebView.AllowsBackForwardNavigationGestures = true;
#endif
    }
}

Related question for Android: Download files in MAUI Android WebView

2

There are 2 best solutions below

7
On BEST ANSWER

You can download files on iOS by handling the DecideDestination event of the WKDownloadDelegate. Also, you need to implement IWKDownloadDelegate and IWKNavigationDelegate and override the default response to show the downloaded file. Here's a solution provided by Tim for your reference:

using ObjCRuntime;
using WebKit;

namespace WebviewTestCatalyst;

[Register("AppDelegate")]
public class AppDelegate : UIApplicationDelegate
{
    public override UIWindow? Window { get; set; }
    public override bool FinishedLaunching(UIApplication application, NSDictionary launchOptions)
    {
        // create a new window instance based on the screen size
        Window = new UIWindow(UIScreen.MainScreen.Bounds);

        // create a UIViewController with a single UILabel
        var vc = new UIViewController();
        var webview = new TestDownloadWebView(Window!.Frame, new WebKit.WKWebViewConfiguration())
        {
            AutoresizingMask = UIViewAutoresizing.All
        };

        vc.View!.AddSubview(webview);
        Window.RootViewController = vc;
        // make the window visible
        Window.MakeKeyAndVisible();
        webview.LoadRequest(new NSUrlRequest(
            new NSUrl("https://file-examples.com/index.php/sample-documents-download/sample-pdf-download/")));
        return true;
    }

    public class TestDownloadWebView : WKWebView, IWKDownloadDelegate, IWKNavigationDelegate
    {
        public TestDownloadWebView(CGRect frame, WKWebViewConfiguration configuration) : base(frame, configuration)
        {
            this.NavigationDelegate = this;
        }
        public void DecideDestination(WKDownload download, NSUrlResponse response, string suggestedFilename,
            Action<NSUrl> completionHandler)
        {
            var destinationURL = GetDestinationURL();
            completionHandler?.Invoke(destinationURL);
        }

        [Export("webView:decidePolicyForNavigationResponse:decisionHandler:")]
        public void DecidePolicy(WKWebView webView, WKNavigationResponse navigationResponse, Action<WKNavigationResponsePolicy> decisionHandler)
        {
            var url = navigationResponse.Response.Url;
            var mimeType = navigationResponse.Response.MimeType;
            Console.WriteLine($"Content-Type: {mimeType}");
            // Perform any actions based on the content type
            if (mimeType == "application/pdf")
            {
                // Download the PDF file separately instead of loading it in the WKWebView
                DownloadPDF(url);

                decisionHandler?.Invoke(WKNavigationResponsePolicy.Cancel);
            }
            else
            {
                decisionHandler?.Invoke(WKNavigationResponsePolicy.Allow);
            }
           
        }

        private void DownloadPDF(NSUrl url)
        {
            var downloadTask = NSUrlSession.SharedSession.CreateDownloadTask(url, (location, _, error) =>
            {
                if (location is NSUrl sourceURL && error == null)
                {
                    var destinationURL = GetDestinationURL();

                    try
                    {
                        NSFileManager.DefaultManager.Move(sourceURL, destinationURL, out error);
                        Console.WriteLine($"PDF file downloaded and saved at: {destinationURL.Path}");

                        // Perform any additional actions with the downloaded file
                    }
                    catch (Exception ex)
                    {
                        // Handle file moving error
                    }
                }
                else
                {
                    // Handle download error
                }
            });

            downloadTask.Resume();
        }

        private NSUrl GetDestinationURL()
        {
            // Customize the destination URL as desired
            var documentsURL =
                NSFileManager.DefaultManager.GetUrls(NSSearchPathDirectory.DocumentDirectory, NSSearchPathDomain.User)
                    [0];
            var destinationURL = documentsURL.Append("downloaded_file.pdf", false);

            return destinationURL;
        }
    }
}

1
On

For anyone interested, here's my full solution for downloading a file from a WebView in MAUI on iOS and showing a dialog so the user can choose what to do with it.

For me the biggest issue was that the downloads were supposed to be opened in a new window, which the web view didn't handle as I expected. So I am checking the TargetFrame of all navigation actions and overriding it as necessary.

It also works with a custom WebViewHandler, but that solution requires more code. On the other hand, it could be re-used for multiple web views.

public partial class MainPage : ContentPage
{
    public MainPage()
    {
        InitializeComponent();
    }

    protected override void OnHandlerChanged()
    {
        base.OnHandlerChanged();

#if IOS
        var iosWebView = WebView.Handler.PlatformView as WebKit.WKWebView;

        // Otherwise swiping doesn't work
        iosWebView.AllowsBackForwardNavigationGestures = true;

        // Custom navigation delegate for iOS
        iosWebView.NavigationDelegate = new MyNavigationDelegate();
#endif
    }
}

Platforms\iOS\MyNavigationDelegate.cs:

using Foundation;
using System.Text.RegularExpressions;
using WebKit;

public class MyNavigationDelegate : WKNavigationDelegate
{
    private static readonly Regex _fileNameRegex = new("filename\\*?=['\"]?(?:UTF-\\d['\"]*)?([^;\\r\\n\"']*)['\"]?;?", RegexOptions.Compiled);

    public override void DecidePolicy(WKWebView webView, WKNavigationAction navigationAction, Action<WKNavigationActionPolicy> decisionHandler)
    {
        // Can't navigate away from the main window
        if (navigationAction.TargetFrame?.MainFrame != true)
        {
            // Cancel the original action and load the same request in the web view
            decisionHandler?.Invoke(WKNavigationActionPolicy.Cancel);
            webView.LoadRequest(navigationAction.Request);
            return;
        }

        decisionHandler?.Invoke(WKNavigationActionPolicy.Allow);
    }

    public override void DecidePolicy(WKWebView webView, WKNavigationAction navigationAction, WKWebpagePreferences preferences, Action<WKNavigationActionPolicy, WKWebpagePreferences> decisionHandler)
    {
        // Can't navigate away from the main window
        if (navigationAction.TargetFrame?.MainFrame != true)
        {
            // Cancel the original action and load the same request in the web view
            decisionHandler?.Invoke(WKNavigationActionPolicy.Cancel, preferences);
            webView.LoadRequest(navigationAction.Request);
            return;
        }

        decisionHandler?.Invoke(WKNavigationActionPolicy.Allow, preferences);
    }

    public override void DecidePolicy(WKWebView webView, WKNavigationResponse navigationResponse, Action<WKNavigationResponsePolicy> decisionHandler)
    {
        // Determine whether to treat it as a download
        if (navigationResponse.Response is NSHttpUrlResponse response
            && response.AllHeaderFields.TryGetValue(new NSString("Content-Disposition"), out var headerValue))
        {
            // Handle it as a download and prevent further navigation
            StartDownload(headerValue.ToString(), navigationResponse.Response.Url);
            decisionHandler?.Invoke(WKNavigationResponsePolicy.Cancel);
            return;
        }

        decisionHandler?.Invoke(WKNavigationResponsePolicy.Allow);
    }

    private void StartDownload(string contentDispositionHeader, NSUrl url)
    {
        try
        {
            var message = TryGetFileNameFromContentDisposition(contentDispositionHeader, out var fileName)
                ? $"Downloading {fileName}..."
                : "Downloading...";

            // TODO: Show toast message

            NSUrlSession
                .FromConfiguration(NSUrlSessionConfiguration.DefaultSessionConfiguration, new MyDownloadDelegate(), null)
                .CreateDownloadTask(url)
                .Resume();
        }
        catch (NSErrorException ex)
        {
            // TODO: Show toast message
        }
    }

    private bool TryGetFileNameFromContentDisposition(string contentDisposition, out string fileName)
    {
        if (string.IsNullOrEmpty(contentDisposition))
        {
            fileName = null;
            return false;
        }

        var match = _fileNameRegex.Match(contentDisposition);
        if (!match.Success)
        {
            fileName = null;
            return false;
        }

        // Use first match even though there could be several matched file names
        fileName = match.Groups[1].Value;
        return true;
    }
}

Platforms\iOS\MyDownloadDelegate.cs:

using CoreFoundation;
using Foundation;
using UIKit;
using UniformTypeIdentifiers;

public class MyDownloadDelegate : NSUrlSessionDownloadDelegate
{
    public override void DidFinishDownloading(NSUrlSession session, NSUrlSessionDownloadTask downloadTask, NSUrl location)
    {
        try
        {
            if (downloadTask.Response == null)
            {
                return;
            }

            // Determine the cache folder
            var fileManager = NSFileManager.DefaultManager;
            var tempDir = fileManager.GetUrls(NSSearchPathDirectory.CachesDirectory, NSSearchPathDomain.User).FirstOrDefault();
            if (tempDir == null)
            {
                return;
            }

            var contentType = UTType.CreateFromMimeType(downloadTask.Response.MimeType);
            if (contentType == null)
            {
                return;
            }

            // Determine the file name in the cache folder
            var destinationPath = tempDir.AppendPathComponent(downloadTask.Response.SuggestedFilename, contentType);
            if (destinationPath == null || string.IsNullOrEmpty(destinationPath.Path))
            {
                return;
            }

            // Remove any existing files with the same name
            if (fileManager.FileExists(destinationPath.Path) && !fileManager.Remove(destinationPath, out var removeError))
            {
                return;
            }

            // Copy the downloaded file from the OS temp folder to our cache folder
            if (!fileManager.Copy(location, destinationPath, out var copyError))
            {
                return;
            }

            DispatchQueue.MainQueue.DispatchAsync(() =>
            {
                ShowFileOpenDialog(destinationPath);
            });
        }
        catch (NSErrorException ex)
        {
            // TODO: Show toast message
        }
    }

    private void ShowFileOpenDialog(NSUrl fileUrl)
    {
        try
        {
            var window = UIApplication.SharedApplication.Windows.Last(x => x.IsKeyWindow);

            var viewController = window.RootViewController;
            if (viewController == null || viewController.View == null)
            {
                return;
            }

            // TODO: Apps sometimes cannot open the file
            var documentController = UIDocumentInteractionController.FromUrl(fileUrl);
            documentController.PresentOpenInMenu(viewController.View.Frame, viewController.View, true);
        }
        catch (NSErrorException ex)
        {
            // TODO: Show toast message
        }
    }
}