2 Replies
      Latest reply on Feb 7, 2020 9:35 AM by ANGOmarcello
      RSymp Level 1 Level 1 (0 points)

        Hello forum!

         

        I have a special problem with my iOS App.

         

        My setup is the following:

        • Swift 4 app with minimum target iOS 11
        • the app consists more or less of single view with a WKWebView which is connected to the client portal of a health insurance
        • the client portal provides a login (session cookies) and allows to download pdf documents when logged-in

         

        I want the downloades pdf files to be opened in QuickLookPreviewController instead of WKWebView.

        The customer need is that they want to be able to print or share their documents and I like to use the QuickLookPreview therefore, because it offers the best native feeling for this case.

         

        Now to my problem:

        I managed to download the documents to local storage (temp folder) and to open it in QuickLookPreviewController with a little trick:

        The response of the download is intercepted (by using webView(..., decidePolicyFor ...)) and the download url is used to trigger a separate download (using URLSession.shared.dataTask(with: downloadUrl)) to iPhone storage, because QuickLookPreviewController needs a local file and cannot deal with the download url to the pdf.

        In general this works - most of the time. Since I need to be in a logged-in state to download the pdf, I have to pass the authentication cookies from WKWebView (where I logged-in) to the shared cookie storage (which is used by the download task). This cookie sync between the two cookie storages is a known issue and I think I read all the related postings in the web, but couldn't get it working reliably.

        In my debug logs I can see that my logic works in general, but sometimes the cookies aren't synchronized at all.

         

        Here's my code. I appreciate every help

         

            
            
        import UIKit  
        import WebKit  
        import QuickLook  
          
        class ViewController: UIViewController, WKUIDelegate, WKNavigationDelegate, QLPreviewControllerDataSource {  
          
            @IBOutlet var webView: WKWebView!
            var documentPreviewController = QLPreviewController()
            var documentUrl = URL(fileURLWithPath: "")
        
        
            var webViewCookieStore: WKHTTPCookieStore!
            let webViewConfiguration = WKWebViewConfiguration()
            
        ...  
          
            override func viewDidLoad() {
                super.viewDidLoad()
        
        
                // link the appDelegate to be able to receive the deviceToken
                let appDelegate = UIApplication.shared.delegate as! AppDelegate
                appDelegate.viewController = self
        
        
                // initial configuration of custom JavaScripts
                webViewConfiguration.userContentController = userContentController
        
        
                // QuickLook document preview
                documentPreviewController.dataSource  = self
                
                webView = WKWebView(frame: CGRect.zero, configuration: webViewConfiguration)
                
                // see "The 2 delegates": https://samwize.com/2016/06/08/complete-guide-to-implementing-wkwebview/
                webView.uiDelegate = self
                webView.navigationDelegate = self
        
        
                view.addSubview(webView)
        
        
                webViewCookieStore = webView.configuration.websiteDataStore.httpCookieStore
                ...  
                load("https://clientportal.xyz"!)  
            }  
          
            private func load(_ url: URL) {  
                load(URLRequest(url:url))  
            }  
            private func load(_ req: URLRequest) {  
                var request = req  
                request.setValue(self.deviceToken, forHTTPHeaderField: "iosDeviceToken")  
                request.setValue(self.myVersion as? String, forHTTPHeaderField: "iosVersion")  
                request.setValue(self.myBuild as? String, forHTTPHeaderField: "iosBuild")  
                request.setValue(UIDevice.current.modelName, forHTTPHeaderField: "iosModelName")  
                debugPrintHeaderFields(of: request, withMessage: "Loading request")  
                webView.load(request)  
                debugPrint("Loaded request=\(request.url?.absoluteString ?? "n/a")")  
            }  
          
            /* 
             Intercept decision handling to be able to present documents in QuickLook preview 
             Needs to be intercepted here, because I need the suggestedFilename for download 
             */  
            func webView(_ webView: WKWebView,  
                         decidePolicyFor navigationResponse: WKNavigationResponse,  
                         decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {  
                let url = navigationResponse.response.url  
                if (openInDocumentPreview(url!)) {  
                    let documentUrl = url?.appendingPathComponent(navigationResponse.response.suggestedFilename!)  
                    loadAndDisplayDocumentFrom(url: documentUrl!)  
                    decisionHandler(.cancel)  
                } else {  
                    decisionHandler(.allow)  
                }  
            }  
          
            /* 
             Download the file from the given url and store it locally in the app's temp folder. 
             The stored file is then opened using QuickLook preview. 
             */  
            private func loadAndDisplayDocumentFrom(url downloadUrl : URL) {
                let localFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(downloadUrl.lastPathComponent)
        
        
        
                // getAllCookies needs to be called in main thread??? (https://medium.com/appssemble/wkwebview-and-wkcookiestore-in-ios-11-5b423e0829f8)
        //??? needed?? DispatchQueue.main.async {        
                    self.webViewCookieStore.getAllCookies { (cookies) in
                    for cookie in cookies {
                        if cookie.domain.range(of: "my.domain.xyz") != nil {
                            HTTPCookieStorage.shared.setCookie(cookie)
                            debugPrint("Sync cookie [\(cookie.domain)] \(cookie.name)=\(cookie.value)")
                        } else {
                            debugPrint("Skip cookie [\(cookie.domain)] \(cookie.name)=\(cookie.value)")
                        }
                    }
                    debugPrint("FINISHED COOKIE SYNC")
                    
                    debugPrint("Downloading document from url=\(downloadUrl.absoluteString)")
                    URLSession.shared.dataTask(with: downloadUrl) { data, response, err in
                        guard let data = data, err == nil else {
                            debugPrint("Error while downloading document from url=\(downloadUrl.absoluteString): \(err.debugDescription)")
                            return
                        }
                        
                        if let httpResponse = response as? HTTPURLResponse {
                            debugPrint("Download http status=\(httpResponse.statusCode)")
                        }
        
        
        
        
                        // write the downloaded data to a temporary folder
                        do {
                            try data.write(to: localFileURL, options: .atomic)   // atomic option overwrites it if needed
                            debugPrint("Stored document from url=\(downloadUrl.absoluteString) in folder=\(localFileURL.absoluteString)")
                            
                            DispatchQueue.main.async {
                                self.documentUrl = localFileURL
                                self.documentPreviewController.refreshCurrentPreviewItem()
                                self.present(self.documentPreviewController, animated: true, completion: nil)
                            }
                        } catch {
                            debugPrint(error)
                            return
                        }
                    }.resume()
                }
            }
          
          
            func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {  
                return documentUrl as QLPreviewItem  
            }  
          
            /* 
             We always have just one preview item 
             */  
            func numberOfPreviewItems(in controller: QLPreviewController) -> Int {  
                return 1  
            }  
          
          
            /* 
             Checks if the given url points to a document provided by Vaadin FileDownloader and returns 'true' if yes 
             */  
            private func openInDocumentPreview(_ url : URL) -> Bool {  
                return url.absoluteString.contains("/APP/connector")  
            }  
        
        

        P.S. I tried many solutions:

        • Reading and syncing the cookies in main thread (there are many hints in the web, that you have to acces cookies in this way) - does not work reliably
        • Reading the WKWebViews cookies via Javascript to avoid the main thread handling - but here I get too less information about the cookies (e.g. domain and path is missing)
        • I configured a WKProcessPool - didn't help

         

        Most of the time the cookies are synchronized, but with the same implementation I get problems when trying it later on, e.g. when I uploaded the presumably working code to TestFlight. It even differs when using a solution in Simulator or on a real device.

         

        The error is a downloaded file (HTTP status 200), but its size is less than 8 kB, because I wasn't allowed to download it it because of the missing synchronized authentication cookie.

        • Re: How to download a PDF via WKWebView but open it in QuickLookPreview?
          RSymp Level 1 Level 1 (0 points)

          Hi again

           

          With external help, I was able to fix my problem.

           

          The solution ist now to avoid the separate download task with URLSession.shared.dataTask and hence avoid the sync problem between the WKWebView cookie storage and the shared cookie storage.

           

          The download ist now performed with JavaScript.

          I'm able to detect and intercept the request in func webView(_ webView: WKWebView, decidePolicyFor navigationAction ...).

          When such a request is detected, I cancel the decision handling and call the JavaScript part with the download.

           

          The JavaScript is executed in the WKWebView and hence I have all the authorization cookies in place and the download is successfull.

          JavaScript then returns the downloaded document as binary blob which is taken over by a JavaScript message handler in Swift.

           

          Then I just have to write the blob to a file and open the file in QuickLook preview. Works like a charm!

           

          Here's the code in case somebody struggles with the same problem:

           

          class ViewController: UIViewController, WKUIDelegate, WKNavigationDelegate, QLPreviewControllerDataSource, WKScriptMessageHandler {
          
          
              ...
          
          
              @IBOutlet var webView: WKWebView!
              var documentPreviewController = QLPreviewController()
              var documentUrl = URL(fileURLWithPath: "")
              var documentDownloadTask: URLSessionTask?
          
          
              override func viewDidLoad() {
                  super.viewDidLoad()
                  
                  // initial configuration of custom JavaScripts
                  webViewConfiguration.userContentController = userContentController
                  webViewConfiguration.websiteDataStore = WKWebsiteDataStore.default()
          
          
                  // init this view controller to receive JavaScript callbacks
                  userContentController.add(self, name: "openDocument")
                  userContentController.add(self, name: "jsError")
          
          
                  // QuickLook document preview
                  documentPreviewController.dataSource  = self
                  
                  webView = WKWebView(frame: CGRect.zero, configuration: webViewConfiguration)
                  ...
              }
              
              func webView(_ webView: WKWebView,
                           decidePolicyFor navigationAction: WKNavigationAction,
                           decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
                  let url = navigationAction.request.url
          
          
            if openInDocumentPreview(url!) {
            decisionHandler(.cancel)
            executeDocumentDownloadScript(forAbsoluteUrl: url!.absoluteString)
            } else {
            decisionHandler(.allow)
            }
          
          
              }
              
              /*
               Handler method for JavaScript calls.
               Receive JavaScript message with downloaded document
               */
              public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
                  debugPrint("did receive message \(message.name)")
          
          
                  if (message.name == "openDocument") {
                      previewDocument(messageBody: message.body as! String)
                  } else if (message.name == "jsError") {
                      debugPrint(message.body as! String)
                  }
              }
              
              /*
               Open downloaded document in QuickLook preview
               */
              private func previewDocument(messageBody: String) {
                  // messageBody is in the format ;data:;base64,
                  
                  // split on the first ";", to reveal the filename
                  let filenameSplits = messageBody.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: false)
                  
                  let filename = String(filenameSplits[0])
                  
                  // split the remaining part on the first ",", to reveal the base64 data
                  let dataSplits = filenameSplits[1].split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false)
                  
                  let data = Data(base64Encoded: String(dataSplits[1]))
                  
                  if (data == nil) {
                      debugPrint("Could not construct data from base64")
                      return
                  }
                  
                  // store the file on disk (.removingPercentEncoding removes possible URL encoded characters like "%20" for blank)
                  let localFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename.removingPercentEncoding ?? filename)
                  
                  do {
                      try data!.write(to: localFileURL);
                  } catch {
                      debugPrint(error)
                      return
                  }
                  
                  // and display it in QL
                  DispatchQueue.main.async {
                      self.documentUrl = localFileURL
                      self.documentPreviewController.refreshCurrentPreviewItem()
                      self.present(self.documentPreviewController, animated: true, completion: nil)
                  }
              }
              
              /*
               Implementation for QLPreviewControllerDataSource
               */
              func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
                  return documentUrl as QLPreviewItem
              }
          
          
              /*
               Implementation for QLPreviewControllerDataSource
               We always have just one preview item
               */
              func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
                  return 1
              }
          
          
              /*
               Checks if the given url points to a document download url
               */
              private func openInDocumentPreview(_ url : URL) -> Bool {
              // this is specific for our application - can be everything in your application
                  return url.absoluteString.contains("/APP/connector")
              }
          
              /*
               Intercept the download of documents in webView, trigger the download in JavaScript and pass the binary file to JavaScript handler in Swift code
               */
              private func executeDocumentDownloadScript(forAbsoluteUrl absoluteUrl : String) {
                  // TODO: Add more supported mime-types for missing content-disposition headers
                  webView.evaluateJavaScript("""
                      (async function download() {
                          const url = '\(absoluteUrl)';
                          try {
                              // we use a second try block here to have more detailed error information
                              // because of the nature of JS the outer try-catch doesn't know anything where the error happended
                              let res;
                              try {
                                  res = await fetch(url, {
                                      credentials: 'include'
                                  });
                              } catch (err) {
                                  window.webkit.messageHandlers.jsError.postMessage(`fetch threw, error: ${err}, url: ${url}`);
                                  return;
                              }
                              if (!res.ok) {
                                  window.webkit.messageHandlers.jsError.postMessage(`Response status was not ok, status: ${res.status}, url: ${url}`);
                                  return;
                              }
                              const contentDisp = res.headers.get('content-disposition');
                              if (contentDisp) {
                                  const match = contentDisp.match(/(^;|)\\s*filename=\\s*(\"([^\"]*)\"|([^;\\s]*))\\s*(;|$)/i);
                                  if (match) {
                                      filename = match[3] || match[4];
                                  } else {
                                      // TODO: we could here guess the filename from the mime-type (e.g. unnamed.pdf for pdfs, or unnamed.tiff for tiffs)
                                      window.webkit.messageHandlers.jsError.postMessage(`content-disposition header could not be matched against regex, content-disposition: ${contentDisp} url: ${url}`);
                                  }
                              } else {
                                  window.webkit.messageHandlers.jsError.postMessage(`content-disposition header missing, url: ${url}`);
                                  return;
                              }
                              if (!filename) {
                                  const contentType = res.headers.get('content-type');
                                  if (contentType) {
                                      if (contentType.indexOf('application/json') === 0) {
                                          filename = 'unnamed.pdf';
                                      } else if (contentType.indexOf('image/tiff') === 0) {
                                          filename = 'unnamed.tiff';
                                      }
                                  }
                              }
                              if (!filename) {
                                  window.webkit.messageHandlers.jsError.postMessage(`Could not determine filename from content-disposition nor content-type, content-dispositon: ${contentDispositon}, content-type: ${contentType}, url: ${url}`);
                              }
                              let data;
                              try {
                                  data = await res.blob();
                              } catch (err) {
                                  window.webkit.messageHandlers.jsError.postMessage(`res.blob() threw, error: ${err}, url: ${url}`);
                                  return;
                              }
                              const fr = new FileReader();
                              fr.onload = () => {
                                  window.webkit.messageHandlers.openDocument.postMessage(`${filename};${fr.result}`)
                              };
                              fr.addEventListener('error', (err) => {
                                  window.webkit.messageHandlers.jsError.postMessage(`FileReader threw, error: ${err}`)
                              })
                              fr.readAsDataURL(data);
                          } catch (err) {
                              // TODO: better log the error, currently only TypeError: Type error
                              window.webkit.messageHandlers.jsError.postMessage(`JSError while downloading document, url: ${url}, err: ${err}`)
                          }
                      })();
                      // null is needed here as this eval returns the last statement and we can't return a promise
                      null;
                  """) { (result, err) in
                      if (err != nil) {
                          debugPrint("JS ERR: \(String(describing: err))")
                      }
                  }
              }
          }