diff --git a/WooCommerce/Classes/Authentication/WebAuth/LinkBehavior.swift b/WooCommerce/Classes/Authentication/WebAuth/LinkBehavior.swift new file mode 100644 index 00000000000..45d2098237b --- /dev/null +++ b/WooCommerce/Classes/Authentication/WebAuth/LinkBehavior.swift @@ -0,0 +1,40 @@ +import Foundation +import WebKit + +enum LinkBehavior { + case all + case hostOnly(URL) + case urlOnly(URL) + + func handle(navigationAction: WKNavigationAction, for webView: WKWebView) -> WKNavigationActionPolicy { + + // We only want to apply this policy for links, not for all resource loads + guard navigationAction.navigationType == .linkActivated && navigationAction.request.url == navigationAction.request.mainDocumentURL else { + return .allow + } + + // Should not happen, but future checks will not work if we can't check the URL + guard let navigationURL = navigationAction.request.url else { + return .allow + } + + switch self { + case .all: + return .allow + case .hostOnly(let url): + if navigationAction.request.url?.host == url.host { + return .allow + } else { + UIApplication.shared.open(navigationURL) + return .cancel + } + case .urlOnly(let url): + if navigationAction.request.url?.absoluteString == url.absoluteString { + return .allow + } else { + UIApplication.shared.open(navigationURL) + return .cancel + } + } + } +} diff --git a/WooCommerce/Classes/Authentication/WebAuth/NavigationTitleView.swift b/WooCommerce/Classes/Authentication/WebAuth/NavigationTitleView.swift new file mode 100644 index 00000000000..53d13a9d62f --- /dev/null +++ b/WooCommerce/Classes/Authentication/WebAuth/NavigationTitleView.swift @@ -0,0 +1,62 @@ +import Foundation +import UIKit +import WordPressShared.WPFontManager + +open class NavigationTitleView: UIView { + @objc public let titleLabel = UILabel(frame: defaultTitleFrame) + @objc public let subtitleLabel = UILabel(frame: defaultSubtitleFrame) + + + // MARK: - UIView's Methods + convenience init() { + self.init(frame: NavigationTitleView.defaultViewFrame) + } + + @objc convenience init(title: String?, subtitle: String?) { + self.init() + titleLabel.text = title ?? String() + subtitleLabel.text = subtitle ?? String() + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupSubviews() + } + + required public init(coder aDecoder: NSCoder) { + super.init(coder: aDecoder)! + setupSubviews() + } + + + // MARK: - Helpers + fileprivate func setupSubviews() { + titleLabel.font = WPFontManager.systemSemiBoldFont(ofSize: NavigationTitleView.defaultTitleFontSize) + titleLabel.textColor = .white + titleLabel.textAlignment = .center + titleLabel.backgroundColor = .clear + titleLabel.autoresizingMask = .flexibleWidth + + subtitleLabel.font = WPFontManager.systemRegularFont(ofSize: NavigationTitleView.defaultSubtitleFontSize) + subtitleLabel.textColor = .white + subtitleLabel.textAlignment = .center + subtitleLabel.backgroundColor = .clear + subtitleLabel.autoresizingMask = .flexibleWidth + + backgroundColor = UIColor.clear + autoresizingMask = [.flexibleWidth, .flexibleBottomMargin, .flexibleTopMargin] + clipsToBounds = true + + addSubview(titleLabel) + addSubview(subtitleLabel) + } + + // MARK: - Static Constants + fileprivate static let defaultViewFrame = CGRect(x: 0.0, y: 0.0, width: 200.0, height: 35.0) + + fileprivate static let defaultTitleFontSize = CGFloat(15) + fileprivate static let defaultTitleFrame = CGRect(x: 0.0, y: 0.0, width: 200.0, height: 19.0) + + fileprivate static let defaultSubtitleFontSize = CGFloat(10) + fileprivate static let defaultSubtitleFrame = CGRect(x: 0.0, y: 19.0, width: 200.0, height: 16.0) +} diff --git a/WooCommerce/Classes/Authentication/WebAuth/WebKitViewController.swift b/WooCommerce/Classes/Authentication/WebAuth/WebKitViewController.swift new file mode 100644 index 00000000000..8eb83b94cba --- /dev/null +++ b/WooCommerce/Classes/Authentication/WebAuth/WebKitViewController.swift @@ -0,0 +1,511 @@ +import Foundation +import Gridicons +import UIKit +import WebKit +import WordPressShared + +/// Partial copy of the same file from WP-iOS +/// https://github.com/wordpress-mobile/WordPress-iOS/blob/c027ccf05ba839d658f8496e62b7bfdae6608a10/WordPress/Classes/Utility/WebKitViewController.swift + +protocol WebKitAuthenticatable { + var authenticator: RequestAuthenticator? { get } + func authenticatedRequest(for url: URL, on webView: WKWebView, completion: @escaping (URLRequest) -> Void) +} + +extension WebKitAuthenticatable { + func authenticatedRequest(for url: URL, on webView: WKWebView, completion: @escaping (URLRequest) -> Void) { + guard let authenticator = authenticator else { + return completion(URLRequest(url: url)) + } + + DispatchQueue.main.async { + let cookieStore = webView.configuration.websiteDataStore.httpCookieStore + authenticator.request(url: url, cookieJar: cookieStore) { (request) in + completion(request) + } + } + } +} + +class WebKitViewController: UIViewController, WebKitAuthenticatable { + @objc let webView: WKWebView + @objc let progressView = WebProgressView() + @objc let titleView = NavigationTitleView() + + @objc lazy var backButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: UIImage.gridicon(.chevronLeft).imageFlippedForRightToLeftLayoutDirection(), + style: .plain, + target: self, + action: #selector(goBack)) + button.title = NSLocalizedString("Back", comment: "Previous web page") + return button + }() + @objc lazy var forwardButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: .gridicon(.chevronRight), + style: .plain, + target: self, + action: #selector(goForward)) + button.title = NSLocalizedString("Forward", comment: "Next web page") + return button + }() + @objc lazy var shareButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: .gridicon(.shareiOS), + style: .plain, + target: self, + action: #selector(share)) + button.title = NSLocalizedString("Share", comment: "Button label to share a web page") + return button + }() + @objc lazy var safariButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: .gridicon(.globe), + style: .plain, + target: self, + action: #selector(openInSafari)) + button.title = NSLocalizedString("Safari", comment: "Button label to open web page in Safari") + button.accessibilityHint = NSLocalizedString("Opens the web page in Safari", comment: "Accessibility hint to open web page in Safari") + return button + }() + @objc lazy var refreshButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: .gridicon(.refresh), style: .plain, target: self, action: #selector(WebKitViewController.refresh)) + button.title = NSLocalizedString("Refresh", comment: "Button label to refresh a web page") + return button + }() + @objc lazy var closeButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: .gridicon(.cross), style: .plain, target: self, action: #selector(WebKitViewController.close)) + button.title = NSLocalizedString("webKit.button.dismiss", value: "Dismiss", comment: "Verb. Dismiss the web view screen.") + return button + }() + + @objc let url: URL? + @objc let authenticator: RequestAuthenticator? + @objc var secureInteraction = false + @objc var customTitle: String? + private let opensNewInSafari: Bool + private let linkBehavior: LinkBehavior + + private var widthConstraint: NSLayoutConstraint? + private var stackViewBottomAnchor: NSLayoutConstraint? + private var onClose: (() -> Void)? + + + private struct WebViewErrors { + static let frameLoadInterrupted = 102 + } + + /// Precautionary variable that's in place to make sure the web view doesn't run into an endless loop of reloads if it encounters an error. + private var hasAttemptedAuthRecovery = false + + @objc init(configuration: WebViewControllerConfiguration) { + let config = WKWebViewConfiguration() + // The default on iPad is true. We want the iPhone to be true as well. + config.allowsInlineMediaPlayback = true + + webView = WKWebView(frame: .zero, configuration: config) + url = configuration.url + secureInteraction = configuration.secureInteraction + customTitle = configuration.customTitle + authenticator = configuration.authenticator + linkBehavior = configuration.linkBehavior + opensNewInSafari = configuration.opensNewInSafari + onClose = configuration.onClose + + super.init(nibName: nil, bundle: nil) + hidesBottomBarWhenPushed = true + startObservingWebView() + } + + fileprivate init(url: URL, parent: WebKitViewController, configuration: WKWebViewConfiguration, source: String? = nil) { + webView = WKWebView(frame: .zero, configuration: configuration) + self.url = url + secureInteraction = parent.secureInteraction + customTitle = parent.customTitle + authenticator = parent.authenticator + linkBehavior = parent.linkBehavior + opensNewInSafari = parent.opensNewInSafari + super.init(nibName: nil, bundle: nil) + hidesBottomBarWhenPushed = true + startObservingWebView() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.title)) + webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.url)) + webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress)) + webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.isLoading)) + } + + private func startObservingWebView() { + webView.addObserver(self, forKeyPath: #keyPath(WKWebView.title), options: [.new], context: nil) + webView.addObserver(self, forKeyPath: #keyPath(WKWebView.url), options: [.new], context: nil) + webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: [.new], context: nil) + webView.addObserver(self, forKeyPath: #keyPath(WKWebView.isLoading), options: [], context: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = UIColor(light: UIColor.gray(.shade0), dark: .basicBackground) + + let stackView = UIStackView(arrangedSubviews: [ + progressView, + webView + ]) + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stackView) + + let edgeConstraints = [ + view.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), + view.topAnchor.constraint(equalTo: stackView.topAnchor), + view.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), + ] + edgeConstraints.forEach({ $0.priority = UILayoutPriority(rawValue: UILayoutPriority.defaultHigh.rawValue - 1) }) + + NSLayoutConstraint.activate(edgeConstraints) + + // we are pinning the top and bottom of the stack view to the safe area to prevent unintentionally hidden content/overlaps (ie cookie acceptance popup) + // then center the horizontal constraints vertically + let safeArea = self.view.safeAreaLayoutGuide + + stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + stackView.topAnchor.constraint(equalTo: safeArea.topAnchor).isActive = true + + // this constraint saved as a varible so it can be deactivated when the toolbar is hidden, to prevent unintended pinning to the safe area + let stackViewBottom = stackView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor) + stackViewBottomAnchor = stackViewBottom + NSLayoutConstraint.activate([stackViewBottom]) + + let stackWidthConstraint = stackView.widthAnchor.constraint(equalToConstant: 0) + stackWidthConstraint.priority = UILayoutPriority.defaultLow + widthConstraint = stackWidthConstraint + NSLayoutConstraint.activate([stackWidthConstraint]) + + configureNavigation() + configureToolbar() + webView.navigationDelegate = self + webView.uiDelegate = self + + loadWebViewRequest() + } + + @objc func loadWebViewRequest() { + guard let url = url else { + return + } + + authenticatedRequest(for: url, on: webView) { [weak self] (request) in + self?.webView.load(request) + } + } + + // MARK: Navigation bar setup + + @objc func configureNavigation() { + setupNavBarTitleView() + setupRefreshButton() + + // Modal styling + // Proceed only if this Modal, and it's the only view in the stack. + // We're not changing the NavigationBar style, if we're sharing it with someone else! + guard isModal() else { + return + } + + setupCloseButton() + styleNavBar() + } + + private func setupRefreshButton() { + if !secureInteraction { + navigationItem.rightBarButtonItem = refreshButton + } + } + + private func setupCloseButton() { + navigationItem.leftBarButtonItem = closeButton + } + + private func setupNavBarTitleView() { + titleView.titleLabel.text = NSLocalizedString("Loading...", comment: "Loading. Verb") + + titleView.titleLabel.textColor = .text + titleView.subtitleLabel.textColor = .neutral(.shade30) + + if let title = customTitle { + self.title = title + } else { + navigationItem.titleView = titleView + } + } + + private func styleNavBar() { + guard let navigationBar = navigationController?.navigationBar else { + return + } + navigationBar.barStyle = .default + + // Remove serif title bar formatting + navigationBar.standardAppearance.titleTextAttributes = [:] + + navigationBar.shadowImage = UIImage(color: .init(UIColor(red: 46.0/255.0, green: 68.0/255.0, blue: 83.0/255.0, alpha: 0.15))) + navigationBar.setBackgroundImage(UIImage(color: .white), for: .default) + + fixBarButtonsColorForBoldText(on: navigationBar) + } + + // MARK: ToolBar setup + + @objc func configureToolbar() { + navigationController?.isToolbarHidden = secureInteraction + + guard !secureInteraction else { + // if not a secure interaction/view, no toolbar is displayed, so deactivate constraint pinning stack view to safe area + stackViewBottomAnchor?.isActive = false + return + } + + styleToolBar() + configureToolbarButtons() + styleToolBarButtons() + } + + func configureToolbarButtons() { + + let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + + let items = [ + backButton, + space, + forwardButton, + space, + shareButton, + space, + safariButton + ] + setToolbarItems(items, animated: false) + } + + private func styleToolBar() { + guard let toolBar = navigationController?.toolbar else { + return + } + + let appearance = UIToolbarAppearance() + appearance.configureWithDefaultBackground() + appearance.backgroundColor = UIColor(light: .white, dark: .gray) + + toolBar.standardAppearance = appearance + + if #available(iOS 15.0, *) { + toolBar.scrollEdgeAppearance = appearance + } + + fixBarButtonsColorForBoldText(on: toolBar) + } + + private func styleToolBarButtons() { + navigationController?.toolbar.items?.forEach(styleToolBarButton) + } + + /// Sets the width of the web preview + /// - Parameter width: The width value to set the webView to + /// - Parameter viewWidth: The view width the webView must fit within, used to manage view transitions, e.g. orientation change + func setWidth(_ width: CGFloat?, viewWidth: CGFloat? = nil) { + if let width = width { + let horizontalViewBound: CGFloat + if let viewWidth = viewWidth { + horizontalViewBound = viewWidth + } else if let superViewWidth = view.superview?.frame.width { + horizontalViewBound = superViewWidth + } else { + horizontalViewBound = width + } + + widthConstraint?.constant = min(width, horizontalViewBound) + widthConstraint?.priority = UILayoutPriority.defaultHigh + } else { + widthConstraint?.priority = UILayoutPriority.defaultLow + } + } + + // MARK: Helpers + + private func fixBarButtonsColorForBoldText(on bar: UIView) { + if UIAccessibility.isBoldTextEnabled { + bar.tintColor = .listIcon + } + } + + private func styleBarButton(_ button: UIBarButtonItem) { + button.tintColor = .listIcon + } + + private func styleToolBarButton(_ button: UIBarButtonItem) { + button.tintColor = .listIcon + } + + // MARK: User Actions + @objc func close() { + dismiss(animated: true, completion: onClose) + } + + @objc func share() { + guard let url = webView.url else { + return + } + + let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) + activityViewController.modalPresentationStyle = .popover + activityViewController.popoverPresentationController?.barButtonItem = shareButton + + present(activityViewController, animated: true) + } + + @objc func refresh() { + webView.reload() + } + + @objc func goBack() { + webView.goBack() + } + + @objc func goForward() { + webView.goForward() + } + + @objc func openInSafari() { + guard let url = webView.url else { + return + } + UIApplication.shared.open(url) + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + guard let object = object as? WKWebView, + object == webView, + let keyPath = keyPath else { + return + } + + switch keyPath { + case #keyPath(WKWebView.title): + titleView.titleLabel.text = webView.title + case #keyPath(WKWebView.url): + // If the site has no title, use the url. + if webView.title?.nonEmptyString() == nil { + titleView.titleLabel.text = webView.url?.host + } + titleView.subtitleLabel.text = webView.url?.host + let haveUrl = webView.url != nil + shareButton.isEnabled = haveUrl + safariButton.isEnabled = haveUrl + navigationItem.rightBarButtonItems?.forEach { $0.isEnabled = haveUrl } + case #keyPath(WKWebView.estimatedProgress): + progressView.progress = Float(webView.estimatedProgress) + progressView.isHidden = webView.estimatedProgress == 1 + case #keyPath(WKWebView.isLoading): + backButton.isEnabled = webView.canGoBack + forwardButton.isEnabled = webView.canGoForward + default: + assertionFailure("Observed change to web view that we are not handling") + } + + // Set the title for the HUD which shows up on tap+hold w/ accessibile font sizes enabled + navigationItem.title = "\(titleView.titleLabel.text ?? "")\n\n\(String(describing: titleView.subtitleLabel.text ?? ""))" + + // Accessibility values which emulate those found in Safari + navigationItem.accessibilityLabel = NSLocalizedString("Title", comment: "Accessibility label for web page preview title") + navigationItem.titleView?.accessibilityValue = titleView.titleLabel.text + navigationItem.titleView?.accessibilityTraits = .updatesFrequently + } +} + +extension WebKitViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + + // Allow request if it is to `wp-login` for 2fa + if let url = navigationAction.request.url, authenticator?.isLogin(url: url) == true { + decisionHandler(.allow) + return + } + + // Check for link protocols such as `tel:` and set the correct behavior + if let url = navigationAction.request.url, let scheme = url.scheme { + let linkProtocols = ["tel", "sms", "mailto"] + if linkProtocols.contains(scheme) && UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + decisionHandler(.cancel) + return + } + } + + let policy = linkBehavior.handle(navigationAction: navigationAction, for: webView) + + decisionHandler(policy) + } + + func webView(_ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + guard navigationResponse.isForMainFrame, let authenticator = authenticator, !hasAttemptedAuthRecovery else { + decisionHandler(.allow) + return + } + + let cookieStore = webView.configuration.websiteDataStore.httpCookieStore + authenticator.decideActionFor(response: navigationResponse.response, cookieJar: cookieStore) { [unowned self] action in + switch action { + case .reload: + decisionHandler(.cancel) + + /// We've cleared the stored cookies so let's try again. + self.hasAttemptedAuthRecovery = true + self.loadWebViewRequest() + case .allow: + decisionHandler(.allow) + } + } + } +} + +extension WebKitViewController: WKUIDelegate { + func webView(_ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures) -> WKWebView? { + if navigationAction.targetFrame == nil, + let url = navigationAction.request.url { + + if opensNewInSafari { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } else { + let controller = WebKitViewController(url: url, parent: self, configuration: configuration) + let navController = UINavigationController(rootViewController: controller) + present(navController, animated: true) + return controller.webView + } + } + return nil + } + + func webViewDidClose(_ webView: WKWebView) { + dismiss(animated: true) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + DDLogInfo("\(NSStringFromClass(type(of: self))) Error Loading [\(error)]") + + // Don't show Frame Load Interrupted errors + let code = (error as NSError).code + if code == WebViewErrors.frameLoadInterrupted { + return + } + + DDLogError("WebView \(webView) didFailProvisionalNavigation: \(error.localizedDescription)") + } +} diff --git a/WooCommerce/Classes/Authentication/WebAuth/WebProgressView.swift b/WooCommerce/Classes/Authentication/WebAuth/WebProgressView.swift new file mode 100644 index 00000000000..c66e854a2f3 --- /dev/null +++ b/WooCommerce/Classes/Authentication/WebAuth/WebProgressView.swift @@ -0,0 +1,73 @@ +import UIKit +import WebKit +import WordPressShared + +/// A view to show progress when loading web pages. +/// +/// Since UIWebView doesn't offer any real or estimate loading progress, this +/// shows an initial indication of progress and animates to a full bar when the +/// web view finishes loading. +/// +class WebProgressView: UIProgressView { + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + configure() + } + + @objc func startedLoading() { + alpha = Animation.visibleAlpha + progress = Progress.initial + } + + @objc func finishedLoading() { + UIView.animate(withDuration: Animation.longDuration, animations: { [weak self] in + self?.progress = Progress.final + }, completion: { [weak self] _ in + UIView.animate(withDuration: Animation.shortDuration, animations: { + self?.alpha = Animation.hiddenAlhpa + }) + }) + } + + func observeProgress(webView: WKWebView) { + webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: [.new], context: nil) + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + guard let webView = object as? WKWebView, + let keyPath = keyPath else { + return + } + + switch keyPath { + case #keyPath(WKWebView.estimatedProgress): + progress = Float(webView.estimatedProgress) + isHidden = webView.estimatedProgress == 1 + default: + assertionFailure("Observed change to web view that we are not handling") + } + } + + private func configure() { + progressTintColor = .primary + backgroundColor = .listBackground + progressViewStyle = .bar + } + + private enum Progress { + static let initial = Float(0.1) + static let final = Float(1.0) + } + + private enum Animation { + static let shortDuration = 0.1 + static let longDuration = 0.4 + static let visibleAlpha = CGFloat(1.0) + static let hiddenAlhpa = CGFloat(0.0) + } +} diff --git a/WooCommerce/Classes/Authentication/WebAuth/WebViewControllerConfiguration.swift b/WooCommerce/Classes/Authentication/WebAuth/WebViewControllerConfiguration.swift new file mode 100644 index 00000000000..15561507490 --- /dev/null +++ b/WooCommerce/Classes/Authentication/WebAuth/WebViewControllerConfiguration.swift @@ -0,0 +1,26 @@ +import UIKit +import WebKit +import struct Networking.Site + +class WebViewControllerConfiguration: NSObject { + @objc var url: URL? + @objc var secureInteraction = false + + /// Opens any new pages in Safari. Otherwise, a new web view will be opened + var opensNewInSafari = false + + /// The behavior to use for allowing links to be loaded by the web view based + var linkBehavior = LinkBehavior.all + @objc var customTitle: String? + @objc var authenticator: RequestAuthenticator? + var onClose: (() -> Void)? + + @objc init(url: URL?) { + self.url = url + super.init() + } + + func authenticate(site: Site, username: String, token: String) { + self.authenticator = RequestAuthenticator(site: site, username: username, token: token) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift index 802b368539c..d4fff5d75fb 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift @@ -223,8 +223,19 @@ final class ProductFormViewController: return } - // TODO: Show authenticated WebView - WebviewHelper.launch(url, with: self) + let credentials = ServiceLocator.stores.sessionManager.defaultCredentials + guard let username = credentials?.username, + let token = credentials?.authToken, + let site = ServiceLocator.stores.sessionManager.defaultSite else { + return + } + + let configuration = WebViewControllerConfiguration(url: url) + configuration.secureInteraction = true + configuration.authenticate(site: site, username: username, token: token) + let webKitVC = WebKitViewController(configuration: configuration) + let nc = WooNavigationController(rootViewController: webKitVC) + present(nc, animated: true) } // MARK: Navigation actions diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModel.swift index 2b03e990518..7caa4e7e37a 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModel.swift @@ -154,7 +154,10 @@ final class ProductFormViewModel: ProductFormViewModelProtocol { if featureFlagService.isFeatureFlagEnabled(.productsOnboarding), // The store is hosted on WP.com - stores.sessionManager.defaultSite?.isWordPressComStore == true, + let site = stores.sessionManager.defaultSite, + site.isWordPressComStore, + // In some edge cases loginURL can be empty preventing successful login flow + site.loginURL.isNotEmpty, // Preview existing drafts or new products, that can be saved as a draft (canSaveAsDraft() || originalProductModel.status == .draft), // Do not preview new blank products without any changes diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 30678dcaa63..92fd9b90b9f 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1144,6 +1144,11 @@ ABC3521A374A2355001E3CD6 /* CardReaderSettingsSearchingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC353433EABC5F0EC796222 /* CardReaderSettingsSearchingViewController.swift */; }; ABC35528D2D6BE6F516E5CEF /* InPersonPaymentsOnboardingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC35A4B736A0B2D8348DD08 /* InPersonPaymentsOnboardingError.swift */; }; ABC35F18E744C5576B986CB3 /* InPersonPaymentsUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC35055F8AC8C8EB649F421 /* InPersonPaymentsUnavailableView.swift */; }; + AE1CC33829129A010021C8EF /* LinkBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE1CC33729129A010021C8EF /* LinkBehavior.swift */; }; + AE3AA889290C303B00BE422D /* WebKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA888290C303B00BE422D /* WebKitViewController.swift */; }; + AE3AA88B290C30B900BE422D /* WebViewControllerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA88A290C30B900BE422D /* WebViewControllerConfiguration.swift */; }; + AE3AA88D290C30E800BE422D /* WebProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA88C290C30E800BE422D /* WebProgressView.swift */; }; + AE3AA890290C313600BE422D /* NavigationTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA88F290C313600BE422D /* NavigationTitleView.swift */; }; AE457813275644590092F687 /* OrderStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE457812275644590092F687 /* OrderStatusSection.swift */; }; AE56E73428E76CDB00A1292B /* StoreInfoInlineWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE56E73328E76CDB00A1292B /* StoreInfoInlineWidget.swift */; }; AE56E73628E7787700A1292B /* StoreInfoCircularWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE56E73528E7787700A1292B /* StoreInfoCircularWidget.swift */; }; @@ -3073,6 +3078,11 @@ ABC35055F8AC8C8EB649F421 /* InPersonPaymentsUnavailableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsUnavailableView.swift; sourceTree = ""; }; ABC353433EABC5F0EC796222 /* CardReaderSettingsSearchingViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardReaderSettingsSearchingViewController.swift; sourceTree = ""; }; ABC35A4B736A0B2D8348DD08 /* InPersonPaymentsOnboardingError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsOnboardingError.swift; sourceTree = ""; }; + AE1CC33729129A010021C8EF /* LinkBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkBehavior.swift; sourceTree = ""; }; + AE3AA888290C303B00BE422D /* WebKitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebKitViewController.swift; sourceTree = ""; }; + AE3AA88A290C30B900BE422D /* WebViewControllerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewControllerConfiguration.swift; sourceTree = ""; }; + AE3AA88C290C30E800BE422D /* WebProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebProgressView.swift; sourceTree = ""; }; + AE3AA88F290C313600BE422D /* NavigationTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationTitleView.swift; sourceTree = ""; }; AE457812275644590092F687 /* OrderStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderStatusSection.swift; sourceTree = ""; }; AE56E73328E76CDB00A1292B /* StoreInfoInlineWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInfoInlineWidget.swift; sourceTree = ""; }; AE56E73528E7787700A1292B /* StoreInfoCircularWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInfoCircularWidget.swift; sourceTree = ""; }; @@ -6588,6 +6598,11 @@ AEB4DB96290AE74B00AE4340 /* CookieJar.swift */, AEB4DB9E290AEA6100AE4340 /* AuthenticationService.swift */, AEB4DBA0290AEC0D00AE4340 /* RequestAuthenticator.swift */, + AE3AA88A290C30B900BE422D /* WebViewControllerConfiguration.swift */, + AE3AA888290C303B00BE422D /* WebKitViewController.swift */, + AE3AA88C290C30E800BE422D /* WebProgressView.swift */, + AE3AA88F290C313600BE422D /* NavigationTitleView.swift */, + AE1CC33729129A010021C8EF /* LinkBehavior.swift */, ); path = WebAuth; sourceTree = ""; @@ -9671,6 +9686,7 @@ D802541F2655137A001B2CC1 /* CardPresentModalNonRetryableError.swift in Sources */, B651474527D644FF00C9C4E6 /* CustomerNoteSection.swift in Sources */, 0375799B28227EDE0083F2E1 /* CardPresentPaymentsOnboardingPresenter.swift in Sources */, + AE3AA890290C313600BE422D /* NavigationTitleView.swift in Sources */, 029F29FC24D94106004751CA /* EditableProductVariationModel.swift in Sources */, 0218B4EC242E06F00083A847 /* MediaType+WPMediaType.swift in Sources */, D85A3C5026C153A500C0E026 /* InPersonPaymentsPluginNotActivatedView.swift in Sources */, @@ -9901,6 +9917,7 @@ 025C00682550DE4700FAC222 /* ProductSKUInputScannerViewController.swift in Sources */, 456BEFB626D912EC002AC16C /* AuthenticatedWebView.swift in Sources */, 310D1B482734919E001D55B4 /* InPersonPaymentsLiveSiteInTestModeView.swift in Sources */, + AE1CC33829129A010021C8EF /* LinkBehavior.swift in Sources */, 26F65C9825DEDAF0008FAE29 /* GenerateVariationUseCase.swift in Sources */, 747AA08B2107CF8D0047A89B /* TracksProvider.swift in Sources */, CE1EC8F120B8A408009762BF /* OrderNoteTableViewCell.swift in Sources */, @@ -10363,6 +10380,7 @@ 0379C51B27BFE23F00A7E284 /* RefundConfirmationCardDetailsCell.swift in Sources */, D85136B9231CED5800DD0539 /* ReviewAge.swift in Sources */, 5718852C2465D9EC00E2486F /* ReviewsCoordinator.swift in Sources */, + AE3AA88B290C30B900BE422D /* WebViewControllerConfiguration.swift in Sources */, 26E1BECA251BE5390096D0A1 /* RefundItemTableViewCell.swift in Sources */, DE7B479527A38B8F0018742E /* CouponDetailsViewModel.swift in Sources */, E16058F9285876E600E471D4 /* LeftImageTitleSubtitleTableViewCell.swift in Sources */, @@ -10425,6 +10443,7 @@ 318109E825E5B8D600EE0BE7 /* NumberedListItemTableViewCell.swift in Sources */, DE26B52C277DA11800A2EA0A /* CouponListView.swift in Sources */, AEA622B2274669D3002A9B57 /* AddOrderCoordinator.swift in Sources */, + AE3AA889290C303B00BE422D /* WebKitViewController.swift in Sources */, 4535EE7A281ADD56004212B4 /* CouponCodeInputFormatter.swift in Sources */, DEDB2D262845D31900CE7D35 /* CouponAllowedEmailsViewModel.swift in Sources */, 0282DD96233C960C006A5FDB /* SearchResultCell.swift in Sources */, @@ -10555,6 +10574,7 @@ D449C52626DFBBDB00D75B02 /* WhatsNewFactory.swift in Sources */, 028FA46C257E0D9F00F88A48 /* PlainTextSectionHeaderView.swift in Sources */, 0235595924496D70004BE2B8 /* ProductsSortOrderBottomSheetListSelectorCommand.swift in Sources */, + AE3AA88D290C30E800BE422D /* WebProgressView.swift in Sources */, DE7B479327A38ADA0018742E /* CouponDetails.swift in Sources */, 57A25C7625ACE9BC00A54A62 /* OrderFulfillmentUseCase.swift in Sources */, EEADF622281A40CB001B40F1 /* ShippingValueLocalizer.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModelTests.swift index 38b845557fc..b07144b3557 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModelTests.swift @@ -544,7 +544,7 @@ final class ProductFormViewModelTests: XCTestCase { func test_no_preview_button_for_new_blank_product_without_any_changes() { // Given - sessionManager.defaultSite = Site.fake().copy(isWordPressComStore: true) + sessionManager.defaultSite = Site.fake().copy(loginURL: "http://test.com/wp-login.php", isWordPressComStore: true) let product = Product.fake().copy(statusKey: ProductStatus.published.rawValue) let viewModel = createViewModel(product: product, @@ -561,7 +561,7 @@ final class ProductFormViewModelTests: XCTestCase { func test_preview_button_for_new_product_with_pending_changes() { // Given - sessionManager.defaultSite = Site.fake().copy(isWordPressComStore: true) + sessionManager.defaultSite = Site.fake().copy(loginURL: "http://test.com/wp-login.php", isWordPressComStore: true) let product = Product.fake().copy(statusKey: ProductStatus.published.rawValue) let viewModel = createViewModel(product: product, @@ -579,7 +579,7 @@ final class ProductFormViewModelTests: XCTestCase { func test_no_preview_button_for_existing_published_product_without_any_changes() { // Given - sessionManager.defaultSite = Site.fake().copy(isWordPressComStore: true) + sessionManager.defaultSite = Site.fake().copy(loginURL: "http://test.com/wp-login.php", isWordPressComStore: true) let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.published.rawValue) let viewModel = createViewModel(product: product, @@ -597,7 +597,7 @@ final class ProductFormViewModelTests: XCTestCase { func test_no_preview_button_for_existing_published_product_with_pending_changes() { // Given - sessionManager.defaultSite = Site.fake().copy(isWordPressComStore: true) + sessionManager.defaultSite = Site.fake().copy(loginURL: "http://test.com/wp-login.php", isWordPressComStore: true) let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.published.rawValue) let viewModel = createViewModel(product: product, @@ -614,7 +614,7 @@ final class ProductFormViewModelTests: XCTestCase { func test_preview_button_for_existing_draft_product_without_any_changes() { // Given - sessionManager.defaultSite = Site.fake().copy(isWordPressComStore: true) + sessionManager.defaultSite = Site.fake().copy(loginURL: "http://test.com/wp-login.php", isWordPressComStore: true) let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.draft.rawValue) let viewModel = createViewModel(product: product, @@ -631,7 +631,7 @@ final class ProductFormViewModelTests: XCTestCase { func test_preview_button_for_existing_draft_product_with_pending_changes() { // Given - sessionManager.defaultSite = Site.fake().copy(isWordPressComStore: true) + sessionManager.defaultSite = Site.fake().copy(loginURL: "http://test.com/wp-login.php", isWordPressComStore: true) let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.draft.rawValue) let viewModel = createViewModel(product: product, @@ -649,7 +649,7 @@ final class ProductFormViewModelTests: XCTestCase { func test_no_preview_button_for_existing_product_with_other_status_and_without_any_changes() { // Given - sessionManager.defaultSite = Site.fake().copy(isWordPressComStore: true) + sessionManager.defaultSite = Site.fake().copy(loginURL: "http://test.com/wp-login.php", isWordPressComStore: true) let product = Product.fake().copy(productID: 123, statusKey: "other") let viewModel = createViewModel(product: product, @@ -666,7 +666,7 @@ final class ProductFormViewModelTests: XCTestCase { func test_no_preview_button_for_existing_product_with_other_status_and_pending_changes() { // Given - sessionManager.defaultSite = Site.fake().copy(isWordPressComStore: true) + sessionManager.defaultSite = Site.fake().copy(loginURL: "http://test.com/wp-login.php", isWordPressComStore: true) let product = Product.fake().copy(productID: 123, statusKey: "other") let viewModel = createViewModel(product: product, @@ -684,7 +684,7 @@ final class ProductFormViewModelTests: XCTestCase { func test_no_preview_button_for_any_product_in_read_only_mode() { // Given - sessionManager.defaultSite = Site.fake().copy(isWordPressComStore: true) + sessionManager.defaultSite = Site.fake().copy(loginURL: "http://test.com/wp-login.php", isWordPressComStore: true) let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.published.rawValue) let viewModel = createViewModel(product: product, @@ -702,7 +702,24 @@ final class ProductFormViewModelTests: XCTestCase { func test_no_preview_button_for_existing_draft_product_on_self_hosted_store() { // Given - sessionManager.defaultSite = Site.fake().copy(isWordPressComStore: false) + sessionManager.defaultSite = Site.fake().copy(loginURL: "http://test.com/wp-login.php", isWordPressComStore: false) + + let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.draft.rawValue) + let viewModel = createViewModel(product: product, + formType: .edit, + stores: stores, + featureFlagService: MockFeatureFlagService(isProductsOnboardingEnabled: true)) + + // When + let actionButtons = viewModel.actionButtons + + // Then + XCTAssertEqual(actionButtons, [.publish, .more]) + } + + func test_no_preview_button_for_existing_draft_product_on_site_with_no_preview_url() { + // Given + sessionManager.defaultSite = Site.fake().copy(loginURL: "", isWordPressComStore: true) let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.draft.rawValue) let viewModel = createViewModel(product: product,