import Foundation import UIKit import Display import ComponentFlow import TelegramCore import Postbox import SwiftSignalKit import TelegramPresentationData import TelegramUIPreferences import PresentationDataUtils import AccountContext import WebKit import AppBundle import PromptUI import SafariServices import ShareController import UndoUI import UrlEscaping final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate, WKUIDelegate, UIScrollViewDelegate { private let context: AccountContext private var presentationData: PresentationData let file: TelegramMediaFile private let webView: WKWebView let uuid: UUID private var _state: BrowserContentState private let statePromise: Promise var currentState: BrowserContentState { return self._state } var state: Signal { return self.statePromise.get() } var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in } var openAppUrl: (String) -> Void = { _ in } var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in } var minimize: () -> Void = { } var close: () -> Void = { } var present: (ViewController, Any?) -> Void = { _, _ in } var presentInGlobalOverlay: (ViewController) -> Void = { _ in } var getNavigationController: () -> NavigationController? = { return nil } private var tempFile: TempBoxFile? init(context: AccountContext, presentationData: PresentationData, file: TelegramMediaFile) { self.context = context self.uuid = UUID() self.presentationData = presentationData self.file = file let configuration = WKWebViewConfiguration() self.webView = WKWebView(frame: CGRect(), configuration: configuration) self.webView.allowsLinkPreview = true if #available(iOS 11.0, *) { self.webView.scrollView.contentInsetAdjustmentBehavior = .never } var title: String = "file" var url = "" if let path = self.context.account.postbox.mediaBox.completedResourcePath(file.resource) { var updatedPath = path if let fileName = file.fileName { let tempFile = TempBox.shared.file(path: path, fileName: fileName) updatedPath = tempFile.path self.tempFile = tempFile title = fileName url = updatedPath } let request = URLRequest(url: URL(fileURLWithPath: updatedPath)) self.webView.load(request) } self._state = BrowserContentState(title: title, url: url, estimatedProgress: 0.0, readingProgress: 0.0, contentType: .document) self.statePromise = Promise(self._state) super.init(frame: .zero) self.webView.allowsBackForwardNavigationGestures = true self.webView.scrollView.delegate = self self.webView.scrollView.clipsToBounds = false self.webView.navigationDelegate = self self.webView.uiDelegate = self if #available(iOS 15.0, *) { self.backgroundColor = presentationData.theme.list.plainBackgroundColor self.webView.underPageBackgroundColor = presentationData.theme.list.plainBackgroundColor } self.addSubview(self.webView) self.webView.interactiveTransitionGestureRecognizerTest = { [weak self] point in if let self { if let result = self.webView.hitTest(point, with: nil), let scrollView = findScrollView(view: result), scrollView.isDescendant(of: self.webView) { if scrollView.contentSize.width > scrollView.frame.width, scrollView.contentOffset.x > -scrollView.contentInset.left { return true } } } return false } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData if #available(iOS 15.0, *) { self.backgroundColor = presentationData.theme.list.plainBackgroundColor self.webView.underPageBackgroundColor = presentationData.theme.list.plainBackgroundColor } if let (size, insets, fullInsets) = self.validLayout { self.updateLayout(size: size, insets: insets, fullInsets: fullInsets, safeInsets: .zero, transition: .immediate) } } var currentFontState = BrowserPresentationState.FontState(size: 100, isSerif: false) func updateFontState(_ state: BrowserPresentationState.FontState) { self.updateFontState(state, force: false) } func updateFontState(_ state: BrowserPresentationState.FontState, force: Bool) { self.currentFontState = state let fontFamily = state.isSerif ? "'Georgia, serif'" : "null" let textSizeAdjust = state.size != 100 ? "'\(state.size)%'" : "null" let js = "\(setupFontFunctions) setTelegramFontOverrides(\(fontFamily), \(textSizeAdjust))"; self.webView.evaluateJavaScript(js) { _, _ in } } func toggleInstantView(_ enabled: Bool) { } private var didSetupSearch = false private func setupSearch(completion: @escaping () -> Void) { guard !self.didSetupSearch else { completion() return } let bundle = getAppBundle() guard let scriptPath = bundle.path(forResource: "UIWebViewSearch", ofType: "js") else { return } guard let scriptData = try? Data(contentsOf: URL(fileURLWithPath: scriptPath)) else { return } guard let script = String(data: scriptData, encoding: .utf8) else { return } self.didSetupSearch = true self.webView.evaluateJavaScript(script, completionHandler: { _, error in if error != nil { print() } completion() }) } private var previousQuery: String? func setSearch(_ query: String?, completion: ((Int) -> Void)?) { guard self.previousQuery != query else { return } self.previousQuery = query self.setupSearch { [weak self] in if let query = query { let js = "uiWebview_HighlightAllOccurencesOfString('\(query)')" self?.webView.evaluateJavaScript(js, completionHandler: { [weak self] _, _ in let js = "uiWebview_SearchResultCount" self?.webView.evaluateJavaScript(js, completionHandler: { [weak self] result, _ in if let result = result as? NSNumber { self?.searchResultsCount = result.intValue completion?(result.intValue) } else { completion?(0) } }) }) } else { let js = "uiWebview_RemoveAllHighlights()" self?.webView.evaluateJavaScript(js, completionHandler: nil) self?.currentSearchResult = 0 self?.searchResultsCount = 0 } } } private var currentSearchResult: Int = 0 private var searchResultsCount: Int = 0 func scrollToPreviousSearchResult(completion: ((Int, Int) -> Void)?) { let searchResultsCount = self.searchResultsCount var index = self.currentSearchResult - 1 if index < 0 { index = searchResultsCount - 1 } self.currentSearchResult = index let js = "uiWebview_ScrollTo('\(searchResultsCount - index - 1)')" self.webView.evaluateJavaScript(js, completionHandler: { _, _ in completion?(index, searchResultsCount) }) } func scrollToNextSearchResult(completion: ((Int, Int) -> Void)?) { let searchResultsCount = self.searchResultsCount var index = self.currentSearchResult + 1 if index >= searchResultsCount { index = 0 } self.currentSearchResult = index let js = "uiWebview_ScrollTo('\(searchResultsCount - index - 1)')" self.webView.evaluateJavaScript(js, completionHandler: { _, _ in completion?(index, searchResultsCount) }) } func stop() { self.webView.stopLoading() } func reload() { self.webView.reload() } func navigateBack() { self.webView.goBack() } func navigateForward() { self.webView.goForward() } func navigateTo(historyItem: BrowserContentState.HistoryItem) { if let webItem = historyItem.webItem { self.webView.go(to: webItem) } } func navigateTo(address: String) { let finalUrl = explicitUrl(address) guard let url = URL(string: finalUrl) else { return } self.webView.load(URLRequest(url: url)) } func scrollToTop() { self.webView.scrollView.setContentOffset(CGPoint(x: 0.0, y: -self.webView.scrollView.contentInset.top), animated: true) } private var validLayout: (CGSize, UIEdgeInsets, UIEdgeInsets)? func updateLayout(size: CGSize, insets: UIEdgeInsets, fullInsets: UIEdgeInsets, safeInsets: UIEdgeInsets, transition: ComponentTransition) { self.validLayout = (size, insets, fullInsets) self.previousScrollingOffset = ScrollingOffsetState(value: self.webView.scrollView.contentOffset.y, isDraggingOrDecelerating: self.webView.scrollView.isDragging || self.webView.scrollView.isDecelerating) let webViewFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: size.width - insets.left - insets.right, height: size.height - insets.top - insets.bottom)) var refresh = false if self.webView.frame.width > 0 && webViewFrame.width != self.webView.frame.width { refresh = true } transition.setFrame(view: self.webView, frame: webViewFrame) if refresh { self.webView.reloadInputViews() } self.webView.scrollView.scrollIndicatorInsets = UIEdgeInsets(top: 0.0, left: -insets.left, bottom: 0.0, right: -insets.right) self.webView.scrollView.horizontalScrollIndicatorInsets = UIEdgeInsets(top: 0.0, left: -insets.left, bottom: 0.0, right: -insets.right) } private func updateState(_ f: (BrowserContentState) -> BrowserContentState) { let updated = f(self._state) self._state = updated self.statePromise.set(.single(self._state)) } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == "title" { self.updateState { $0.withUpdatedTitle(self.webView.title ?? "") } } else if keyPath == "URL" { self.updateState { $0.withUpdatedUrl(self.webView.url?.absoluteString ?? "") } self.didSetupSearch = false } else if keyPath == "estimatedProgress" { self.updateState { $0.withUpdatedEstimatedProgress(self.webView.estimatedProgress) } } else if keyPath == "canGoBack" { self.updateState { $0.withUpdatedCanGoBack(self.webView.canGoBack) } self.webView.disablesInteractiveTransitionGestureRecognizer = self.webView.canGoBack } else if keyPath == "canGoForward" { self.updateState { $0.withUpdatedCanGoForward(self.webView.canGoForward) } } } private struct ScrollingOffsetState: Equatable { var value: CGFloat var isDraggingOrDecelerating: Bool } private var previousScrollingOffset: ScrollingOffsetState? func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateScrollingOffset(isReset: false, transition: .immediate) } private func snapScrollingOffsetToInsets() { let transition = ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)) self.updateScrollingOffset(isReset: false, transition: transition) } public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { self.snapScrollingOffsetToInsets() } } public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { self.snapScrollingOffsetToInsets() } private func updateScrollingOffset(isReset: Bool, transition: ComponentTransition) { let scrollView = self.webView.scrollView let isInteracting = scrollView.isDragging || scrollView.isDecelerating if let previousScrollingOffsetValue = self.previousScrollingOffset { let currentBounds = scrollView.bounds let offsetToTopEdge = max(0.0, currentBounds.minY - 0.0) let offsetToBottomEdge = max(0.0, scrollView.contentSize.height - currentBounds.maxY) let relativeOffset = scrollView.contentOffset.y - previousScrollingOffsetValue.value self.onScrollingUpdate(ContentScrollingUpdate( relativeOffset: relativeOffset, absoluteOffsetToTopEdge: offsetToTopEdge, absoluteOffsetToBottomEdge: offsetToBottomEdge, isReset: isReset, isInteracting: isInteracting, transition: transition )) } self.previousScrollingOffset = ScrollingOffsetState(value: scrollView.contentOffset.y, isDraggingOrDecelerating: isInteracting) var readingProgress: CGFloat = 0.0 if !scrollView.contentSize.height.isZero { let value = (scrollView.contentOffset.y + scrollView.contentInset.top) / (scrollView.contentSize.height - scrollView.bounds.size.height + scrollView.contentInset.top) readingProgress = max(0.0, min(1.0, value)) } self.updateState { $0.withUpdatedReadingProgress(readingProgress) } } func resetScrolling() { self.updateScrollingOffset(isReset: true, transition: .spring(duration: 0.4)) } func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { self.updateFontState(self.currentFontState, force: true) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { self.updateState { $0 .withUpdatedBackList(webView.backForwardList.backList.map { BrowserContentState.HistoryItem(webItem: $0) }) .withUpdatedForwardList(webView.backForwardList.forwardList.map { BrowserContentState.HistoryItem(webItem: $0) }) } } func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if navigationAction.targetFrame == nil { if let url = navigationAction.request.url?.absoluteString { self.open(url: url, new: true) } } return nil } func webViewDidClose(_ webView: WKWebView) { self.close() } @available(iOSApplicationExtension 15.0, iOS 15.0, *) func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void) { decisionHandler(.prompt) } private func open(url: String, new: Bool) { let subject: BrowserScreen.Subject = .webPage(url: url) if new, let navigationController = self.getNavigationController() { navigationController._keepModalDismissProgress = true self.minimize() let controller = BrowserScreen(context: self.context, subject: subject) navigationController._keepModalDismissProgress = true navigationController.pushViewController(controller) } else { self.pushContent(subject, nil) } } private func share(url: String) { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let shareController = ShareController(context: self.context, subject: .url(url)) shareController.actionCompleted = { [weak self] in self?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil) } self.present(shareController, nil) } func addToRecentlyVisited() { } func makeContentSnapshotView() -> UIView? { let configuration = WKSnapshotConfiguration() configuration.rect = CGRect(origin: .zero, size: self.webView.frame.size) let imageView = UIImageView() imageView.frame = CGRect(origin: .zero, size: self.webView.frame.size) self.webView.takeSnapshot(with: configuration, completionHandler: { image, _ in imageView.image = image }) return imageView } } private func findScrollView(view: UIView?) -> UIScrollView? { if let view = view { if let view = view as? UIScrollView { return view } return findScrollView(view: view.superview) } else { return nil } }