diff --git a/Core/Logging.swift b/Core/Logging.swift index fd6bdac037..9be20c9153 100644 --- a/Core/Logging.swift +++ b/Core/Logging.swift @@ -31,6 +31,7 @@ public extension OSLog { case autoconsentLog = "DDG Autoconsent" case configurationLog = "DDG Configuration" case syncLog = "DDG Sync" + case duckPlayerLog = "Duck Player" } @OSLogWrapper(.generalLog) static var generalLog @@ -40,6 +41,7 @@ public extension OSLog { @OSLogWrapper(.autoconsentLog) static var autoconsentLog @OSLogWrapper(.configurationLog) static var configurationLog @OSLogWrapper(.syncLog) static var syncLog + @OSLogWrapper(.duckPlayerLog) static var duckPlayerLog // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // To activate Logging Categories add categories here: @@ -50,7 +52,8 @@ public extension OSLog { .adAttributionLog, .lifecycleLog, .configurationLog, - .syncLog + .syncLog, + .duckPlayerLog ] #endif diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index dd3d93e456..94c8cdc6f6 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -150,9 +150,10 @@ public struct UserDefaultsWrapper { case duckPlayerMode = "com.duckduckgo.ios.duckPlayerMode" case duckPlayerAskModeOverlayHidden = "com.duckduckgo.ios.duckPlayerAskModeOverlayHidden" - + case vpnRedditWorkaroundInstalled = "com.duckduckgo.ios.vpn.workaroundInstalled" + // Debug keys case debugNewTabPageSectionsEnabledKey = "com.duckduckgo.ios.debug.newTabPageSectionsEnabled" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index f965cba25c..0bacb46ffd 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -832,7 +832,7 @@ D64648AF2B5993890033090B /* SubscriptionEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64648AE2B5993890033090B /* SubscriptionEmailViewModel.swift */; }; D652498E2B515A6A0056B0DE /* SubscriptionSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D652498D2B515A6A0056B0DE /* SubscriptionSettingsViewModel.swift */; }; D65625902C22D307006EF297 /* DuckPlayerURLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63FF88B2C1B21ED006DE24D /* DuckPlayerURLExtension.swift */; }; - D65625922C22D340006EF297 /* YouTubePlayerNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63FF8892C1B21C2006DE24D /* YouTubePlayerNavigationHandler.swift */; }; + D65625922C22D340006EF297 /* DuckPlayerNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63FF8892C1B21C2006DE24D /* DuckPlayerNavigationHandler.swift */; }; D65625952C22D382006EF297 /* TabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F159BDA31F0BDB5A00B4A01D /* TabViewController.swift */; }; D65625A12C232F5E006EF297 /* SettingsDuckPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65625A02C232F5E006EF297 /* SettingsDuckPlayerView.swift */; }; D65CEA702B6AC6C9008A759B /* Subscription.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D65CEA6F2B6AC6C9008A759B /* Subscription.xcassets */; }; @@ -2516,7 +2516,7 @@ D62EC3C12C248AF800FC9D04 /* DuckNavigationHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckNavigationHandling.swift; sourceTree = ""; }; D63657182A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmailManagerRequestDelegate.swift; sourceTree = ""; }; D63677F42BBDB1C300605BA5 /* DaxLogoNavbarTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaxLogoNavbarTitle.swift; sourceTree = ""; }; - D63FF8892C1B21C2006DE24D /* YouTubePlayerNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubePlayerNavigationHandler.swift; sourceTree = ""; }; + D63FF8892C1B21C2006DE24D /* DuckPlayerNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerNavigationHandler.swift; sourceTree = ""; }; D63FF88B2C1B21ED006DE24D /* DuckPlayerURLExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerURLExtension.swift; sourceTree = ""; }; D63FF8932C1B67E8006DE24D /* YoutubePlayerUserScript.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = YoutubePlayerUserScript.swift; sourceTree = ""; }; D63FF8942C1B67E8006DE24D /* YoutubeOverlayUserScript.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = YoutubeOverlayUserScript.swift; sourceTree = ""; }; @@ -4734,7 +4734,7 @@ D63FF8972C1B6A45006DE24D /* DuckPlayer.swift */, D6037E682C32F2E7009AAEC0 /* DuckPlayerSettings.swift */, D62EC3C12C248AF800FC9D04 /* DuckNavigationHandling.swift */, - D63FF8892C1B21C2006DE24D /* YouTubePlayerNavigationHandler.swift */, + D63FF8892C1B21C2006DE24D /* DuckPlayerNavigationHandler.swift */, D63FF88B2C1B21ED006DE24D /* DuckPlayerURLExtension.swift */, D63FF8942C1B67E8006DE24D /* YoutubeOverlayUserScript.swift */, D63FF8932C1B67E8006DE24D /* YoutubePlayerUserScript.swift */, @@ -6978,7 +6978,7 @@ 1EA51376286596A000493C6A /* PrivacyIconLogic.swift in Sources */, 980891A92238504B00313A70 /* UILabelExtension.swift in Sources */, 984D035A24ACCC7D0066CFB8 /* TabViewCell.swift in Sources */, - D65625922C22D340006EF297 /* YouTubePlayerNavigationHandler.swift in Sources */, + D65625922C22D340006EF297 /* DuckPlayerNavigationHandler.swift in Sources */, 31951E8E2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift in Sources */, F194FAED1F14E2B3009B4DF8 /* UIFontExtension.swift in Sources */, 98F0FC2021FF18E700CE77AB /* AutoClearSettingsViewController.swift in Sources */, diff --git a/DuckDuckGo/AppDependencyProvider.swift b/DuckDuckGo/AppDependencyProvider.swift index 228093422b..fea06bf74e 100644 --- a/DuckDuckGo/AppDependencyProvider.swift +++ b/DuckDuckGo/AppDependencyProvider.swift @@ -47,6 +47,7 @@ protocol DependencyProvider { var networkProtectionKeychainTokenStore: NetworkProtectionKeychainTokenStore { get } var networkProtectionTunnelController: NetworkProtectionTunnelController { get } var connectionObserver: ConnectionStatusObserver { get } + var serverInfoObserver: ConnectionServerInfoObserver { get } var vpnSettings: VPNSettings { get } } @@ -88,6 +89,7 @@ class AppDependencyProvider: DependencyProvider { let subscriptionAppGroup = Bundle.main.appGroup(bundle: .subs) let connectionObserver: ConnectionStatusObserver = ConnectionStatusObserverThroughSession() + let serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession() let vpnSettings = VPNSettings(defaults: .networkProtectionGroupDefaults) init() { diff --git a/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift b/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift index c2e2178472..33ad44fb25 100644 --- a/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift +++ b/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift @@ -21,13 +21,10 @@ import WebKit protocol DuckNavigationHandling { var referrer: DuckPlayerReferrer { get set } - func handleNavigation(_ navigationAction: WKNavigationAction, - webView: WKWebView, - completion: @escaping (WKNavigationActionPolicy) -> Void) + var duckPlayer: DuckPlayerProtocol { get } + func handleNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) func handleURLChange(url: URL?, webView: WKWebView) - func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, - completion: @escaping (WKNavigationActionPolicy) -> Void, - webView: WKWebView) + func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, webView: WKWebView) func handleGoBack(webView: WKWebView) func handleReload(webView: WKWebView) } diff --git a/DuckDuckGo/DuckPlayer/DuckPlayer.swift b/DuckDuckGo/DuckPlayer/DuckPlayer.swift index fb54fd00b9..4192367a0d 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayer.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayer.swift @@ -26,7 +26,7 @@ import UserScript import Core /// Values that the Frontend can use to determine the current state. -struct InitialSetupSettings: Codable { +struct InitialPlayerSettings: Codable { struct PlayerSettings: Codable { let pip: PIP } @@ -34,16 +34,36 @@ struct InitialSetupSettings: Codable { struct PIP: Codable { let status: Status } + + struct Platform: Codable { + let name: String + } enum Status: String, Codable { case enabled case disabled } + + enum Environment: String, Codable { + case development + case production + } + + enum Locale: String, Codable { + case en + } let userValues: UserValues let settings: PlayerSettings + let platform: Platform + let locale: Locale +} + +struct InitialOverlaySettings: Codable { + let userValues: UserValues } + /// Values that the Frontend can use to determine user settings public struct UserValues: Codable { enum CodingKeys: String, CodingKey { @@ -61,13 +81,20 @@ public enum DuckPlayerReferrer { protocol DuckPlayerProtocol { var settings: DuckPlayerSettingsProtocol { get } + var hostView: UIViewController? { get } init(settings: DuckPlayerSettingsProtocol) func setUserValues(params: Any, message: WKScriptMessage) -> Encodable? func getUserValues(params: Any, message: WKScriptMessage) -> Encodable? func openVideoInDuckPlayer(url: URL, webView: WKWebView) - func initialSetup(params: Any, message: WKScriptMessage) async -> Encodable? + func openDuckPlayerSettings(params: Any, message: WKScriptMessage) async -> Encodable? + func openDuckPlayerInfo(params: Any, message: WKScriptMessage) async -> Encodable? + + func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> Encodable? + func initialSetupOverlay(params: Any, message: WKScriptMessage) async -> Encodable? + + func setHostViewController(_ vc: UIViewController) } final class DuckPlayer: DuckPlayerProtocol { @@ -76,11 +103,18 @@ final class DuckPlayer: DuckPlayerProtocol { static let commonName = "Duck Player" private(set) var settings: DuckPlayerSettingsProtocol + private(set) var hostView: UIViewController? init(settings: DuckPlayerSettingsProtocol = DuckPlayerSettings()) { self.settings = settings } + // Sets a presenting VC, so DuckPlayer can present the + // info sheet directly + public func setHostViewController(_ vc: UIViewController) { + hostView = vc + } + // MARK: - Common Message Handlers public func setUserValues(params: Any, message: WKScriptMessage) -> Encodable? { @@ -103,9 +137,35 @@ final class DuckPlayer: DuckPlayerProtocol { } @MainActor - public func initialSetup(params: Any, message: WKScriptMessage) async -> Encodable? { + public func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> Encodable? { + let webView = message.webView + return await self.encodedPlayerSettings(with: webView) + } + + @MainActor + public func initialSetupOverlay(params: Any, message: WKScriptMessage) async -> Encodable? { let webView = message.webView - return await self.encodedSettings(with: webView) + return await self.encodedPlayerSettings(with: webView) + } + + public func openDuckPlayerSettings(params: Any, message: WKScriptMessage) async -> Encodable? { + NotificationCenter.default.post( + name: .settingsDeepLinkNotification, + object: SettingsViewModel.SettingsDeepLinkSection.duckPlayer, + userInfo: nil + ) + return nil + } + + @MainActor + public func presentDuckPlayerInfo() { + guard let hostView else { return } + DuckPlayerModalPresenter().presentDuckPlayerFeatureModal(on: hostView) + } + + public func openDuckPlayerInfo(params: Any, message: WKScriptMessage) async -> Encodable? { + await presentDuckPlayerInfo() + return nil } private func encodeUserValues() -> UserValues { @@ -116,14 +176,21 @@ final class DuckPlayer: DuckPlayerProtocol { } @MainActor - private func encodedSettings(with webView: WKWebView?) async -> InitialSetupSettings { + private func encodedPlayerSettings(with webView: WKWebView?) async -> InitialPlayerSettings { let isPiPEnabled = webView?.configuration.allowsPictureInPictureMediaPlayback == true - let pip = InitialSetupSettings.PIP(status: isPiPEnabled ? .enabled : .disabled) - - let playerSettings = InitialSetupSettings.PlayerSettings(pip: pip) + let pip = InitialPlayerSettings.PIP(status: isPiPEnabled ? .enabled : .disabled) + let platform = InitialPlayerSettings.Platform(name: "ios") + let environment = InitialPlayerSettings.Environment.development + let locale = InitialPlayerSettings.Locale.en + let playerSettings = InitialPlayerSettings.PlayerSettings(pip: pip) let userValues = encodeUserValues() - - return InitialSetupSettings(userValues: userValues, settings: playerSettings) + return InitialPlayerSettings(userValues: userValues, settings: playerSettings, platform: platform, locale: locale) + } + + @MainActor + private func encodedOverlaySettings(with webView: WKWebView?) async -> InitialOverlaySettings { + let userValues = encodeUserValues() + return InitialOverlaySettings(userValues: userValues) } } diff --git a/DuckDuckGo/DuckPlayer/YouTubePlayerNavigationHandler.swift b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift similarity index 64% rename from DuckDuckGo/DuckPlayer/YouTubePlayerNavigationHandler.swift rename to DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift index d5288d97cd..bf93b4dd01 100644 --- a/DuckDuckGo/DuckPlayer/YouTubePlayerNavigationHandler.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift @@ -1,5 +1,5 @@ // -// YouTubePlayerNavigationHandler.swift +// DuckPlayerNavigationHandler.swift // DuckDuckGo // // Copyright © 2024 DuckDuckGo. All rights reserved. @@ -21,11 +21,14 @@ import Foundation import ContentScopeScripts import WebKit import Core +import Common -final class YoutubePlayerNavigationHandler { +final class DuckPlayerNavigationHandler { var duckPlayer: DuckPlayerProtocol var referrer: DuckPlayerReferrer = .other + var lastHandledVideoID: String? + var isDuckPlayerTemporarilyDisabled = false private struct Constants { static let SERPURL = "https://duckduckgo.com/" @@ -38,10 +41,13 @@ final class YoutubePlayerNavigationHandler { static let duckPlayerDefaultString = "default" static let settingsKey = "settings" static let httpMethod = "GET" + static let watchInYoutubePath = "openInYoutube" + static let watchInYoutubeVideoParameter = "v" } init(duckPlayer: DuckPlayerProtocol) { self.duckPlayer = duckPlayer + os_log("DP: Trying to load the same video while in DuckPlayer, use Youtube:", log: .duckPlayerLog, type: .debug) } static var htmlTemplatePath: String { @@ -92,14 +98,33 @@ final class YoutubePlayerNavigationHandler { } -extension YoutubePlayerNavigationHandler: DuckNavigationHandling { +extension DuckPlayerNavigationHandler: DuckNavigationHandling { // Handle rendering the simulated request if the URL is duck:// // and DuckPlayer is either enabled or alwaysAsk @MainActor - func handleNavigation(_ navigationAction: WKNavigationAction, - webView: WKWebView, - completion: @escaping (WKNavigationActionPolicy) -> Void) { + func handleNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) { + + os_log("DP: Handling DuckPlayer Player Navigation for %s", log: .duckPlayerLog, type: .debug, navigationAction.request.url?.absoluteString ?? "") + + // Handle Open in Youtube Links + // duck://player/openInYoutube?v=12345 + if let url = navigationAction.request.url, + url.scheme == "duck" { + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) + + if urlComponents?.path == "/\(Constants.watchInYoutubePath)", + let queryItems = urlComponents?.queryItems { + + if let videoParameterItem = queryItems.first(where: { $0.name == Constants.watchInYoutubeVideoParameter }), + let id = videoParameterItem.value { + // Disable DP temporarily + isDuckPlayerTemporarilyDisabled = true + handleURLChange(url: URL.youtube(id, timestamp: nil), webView: webView) + return + } + } + } // Daily Unique View Pixel if let url = navigationAction.request.url, @@ -110,8 +135,7 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { } // Pixel for Views From Youtube - if let url = navigationAction.request.url, - referrer == .youtube, + if referrer == .youtube, duckPlayer.settings.mode == .enabled { Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromYoutubeAutomatic, debounce: 2) } @@ -119,12 +143,12 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { // If DuckPlayer is Enabled or in ask mode, render the video if let url = navigationAction.request.url, url.isDuckURLScheme, - duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { - let html = Self.makeHTMLFromTemplate() + duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk, + !isDuckPlayerTemporarilyDisabled { let newRequest = Self.makeDuckPlayerRequest(from: URLRequest(url: url)) if #available(iOS 15.0, *) { - webView.loadSimulatedRequest(newRequest, responseHTML: html) - completion(.allow) + os_log("DP: Loading Simulated Request for %s", log: .duckPlayerLog, type: .debug, navigationAction.request.url?.absoluteString ?? "") + performRequest(request: newRequest, webView: webView) return } } @@ -133,13 +157,10 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { if let url = navigationAction.request.url, let (videoID, timestamp) = url.youtubeVideoParams, duckPlayer.settings.mode == .disabled { - webView.load(URLRequest(url: URL.youtube(videoID, timestamp: timestamp))) - completion(.allow) + os_log("DP: is Disabled. We should load original video for %s", log: .duckPlayerLog, type: .debug) + handleURLChange(url: URL.youtube(videoID, timestamp: timestamp), webView: webView) return } - - completion(.allow) - } // Handle URL changes not triggered via Omnibar @@ -147,24 +168,59 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { @MainActor func handleURLChange(url: URL?, webView: WKWebView) { + // Do not handle the URL if the video was just handled + if let url = url, + url.isYoutubeVideo || url.isDuckPlayer, + let (videoID, _) = url.youtubeVideoParams, + lastHandledVideoID == videoID, + !isDuckPlayerTemporarilyDisabled { + return + } + if let url = url, url.isYoutubeVideo, !url.isDuckPlayer, let (videoID, timestamp) = url.youtubeVideoParams, - duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { - webView.stopLoading() - let newURL = URL.duckPlayer(videoID, timestamp: timestamp) + duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { + + os_log("DP: Handling URL change: %s", log: .duckPlayerLog, type: .debug, url.absoluteString) + var newURL = URL.duckPlayer(videoID, timestamp: timestamp) + + // IF DP is temporarily disabled, load Youtube website + // Then reset the setting + if isDuckPlayerTemporarilyDisabled { + os_log("DP: Duckplayer is temporarily disabled. Opening Youtube", log: .duckPlayerLog, type: .debug) + newURL = URL.youtube(videoID, timestamp: timestamp) + } else { + os_log("DP: Duckplayer is NOT disabled. Opening DuckPlayer", log: .duckPlayerLog, type: .debug) + } + + // Load the URL webView.load(URLRequest(url: newURL)) + + // Add a short delay to let the webview start the navigation + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.lastHandledVideoID = videoID + self.isDuckPlayerTemporarilyDisabled = false + } } - } // DecidePolicyFor handler to redirect relevant requests // to duck://player @MainActor func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, - completion: @escaping (WKNavigationActionPolicy) -> Void, webView: WKWebView) { + // Do not handle the URL if the video was just handled + if let url = navigationAction.request.url, + url.isYoutubeVideo || url.isDuckPlayer, + let (videoID, _) = url.youtubeVideoParams, + lastHandledVideoID == videoID, + !isDuckPlayerTemporarilyDisabled { + return + } + + // Pixel for Views From SERP if let url = navigationAction.request.url, navigationAction.request.allHTTPHeaderFields?[Constants.refererHeader] == Constants.SERPURL, @@ -179,21 +235,20 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromOther, debounce: 2) } - if let url = navigationAction.request.url, - url.isYoutubeVideo, - !url.isDuckPlayer, let (videoID, timestamp) = url.youtubeVideoParams, - duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { - webView.load(URLRequest(url: .duckPlayer(videoID, timestamp: timestamp))) - completion(.allow) + url.isYoutubeVideo, + !url.isDuckPlayer, let (videoID, timestamp) = url.youtubeVideoParams, + duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { + os_log("DP: Handling decidePolicy for Duck Player with %s", log: .duckPlayerLog, type: .debug, url.absoluteString) + handleURLChange(url: URL.duckPlayer(videoID, timestamp: timestamp), webView: webView) return } - completion(.allow) } // Handle Webview BackButton on DuckPlayer videos @MainActor func handleGoBack(webView: WKWebView) { + guard let backURL = webView.backForwardList.backItem?.url, backURL.isYoutubeVideo, backURL.youtubeVideoParams?.videoID == webView.url?.youtubeVideoParams?.videoID, @@ -204,14 +259,15 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { webView.goBack(skippingHistoryItems: 2) } - // Handle Reload for DuckPlayer Videos @MainActor func handleReload(webView: WKWebView) { + if let url = webView.url, url.isDuckPlayer, !url.isDuckURLScheme, let (videoID, timestamp) = url.youtubeVideoParams, - duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { + duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk { + os_log("DP: Handling DuckPlayer Reload for %s", log: .duckPlayerLog, type: .debug, url.absoluteString) webView.load(URLRequest(url: .duckPlayer(videoID, timestamp: timestamp))) } else { webView.reload() diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift b/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift index d17a9b3780..5ae878341a 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift @@ -42,8 +42,14 @@ extension URL { } static func youtube(_ videoID: String, timestamp: String? = nil) -> URL { - let url = "https://www.youtube.com/watch?v=\(videoID)".url! - return url.addingTimestamp(timestamp) + #if os(iOS) + let baseUrl = "https://m.youtube.com/watch?v=\(videoID)" + #else + let baseUrl = "https://www.youtube.com/watch?v=\(videoID)" + #endif + + let url = URL(string: baseUrl)! + return url.addingTimestamp(timestamp) } var isDuckURLScheme: Bool { diff --git a/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift b/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift index 091afb731b..261e1d912b 100644 --- a/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift +++ b/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift @@ -107,7 +107,7 @@ final class YoutubeOverlayUserScript: NSObject, Subfeature { case Handlers.sendDuckPlayerPixel: return handleSendJSPixel case Handlers.initialSetup: - return duckPlayer.initialSetup + return duckPlayer.initialSetupOverlay default: assertionFailure("YoutubeOverlayUserScript: Failed to parse User Script message: \(methodName)") // TODO: Send pixel here diff --git a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift index 2536de5f83..e85f0ee19b 100644 --- a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift +++ b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift @@ -35,6 +35,8 @@ final class YoutubePlayerUserScript: NSObject, Subfeature { static let setUserValues = "setUserValues" static let getUserValues = "getUserValues" static let initialSetup = "initialSetup" + static let openSettings = "openSettings" + static let openInfo = "openInfo" } init(duckPlayer: DuckPlayerProtocol) { @@ -72,7 +74,11 @@ final class YoutubePlayerUserScript: NSObject, Subfeature { case Handlers.setUserValues: return duckPlayer.setUserValues case Handlers.initialSetup: - return duckPlayer.initialSetup + return duckPlayer.initialSetupPlayer + case Handlers.openSettings: + return duckPlayer.openDuckPlayerSettings + case Handlers.openInfo: + return duckPlayer.openDuckPlayerInfo default: assertionFailure("YoutubePlayerUserScript: Failed to parse User Script message: \(methodName)") return nil diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 0165019c32..cad0ed02cf 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -113,6 +113,7 @@ class MainViewController: UIViewController { private var favoritesDisplayModeCancellable: AnyCancellable? private var emailCancellables = Set() private var urlInterceptorCancellables = Set() + private var settingsDeepLinkcancellables = Set() #if NETWORK_PROTECTION private let tunnelDefaults = UserDefaults.networkProtectionGroupDefaults @@ -261,6 +262,7 @@ class MainViewController: UIViewController { addLaunchTabNotificationObserver() subscribeToEmailProtectionStatusNotifications() subscribeToURLInterceptorNotifications() + subscribeToSettingsDeeplinkNotifications() #if NETWORK_PROTECTION subscribeToNetworkProtectionEvents() @@ -1349,6 +1351,23 @@ class MainViewController: UIViewController { } .store(in: &urlInterceptorCancellables) } + + private func subscribeToSettingsDeeplinkNotifications() { + NotificationCenter.default.publisher(for: .settingsDeepLinkNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + switch notification.object as? SettingsViewModel.SettingsDeepLinkSection { + + case .duckPlayer: + let deepLinkTarget: SettingsViewModel.SettingsDeepLinkSection + deepLinkTarget = .duckPlayer + self?.launchSettings(deepLinkTarget: deepLinkTarget) + default: + return + } + } + .store(in: &settingsDeepLinkcancellables) + } #if NETWORK_PROTECTION private func subscribeToNetworkProtectionEvents() { diff --git a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift index cfa8429d58..0840e6fe1b 100644 --- a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift +++ b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift @@ -28,7 +28,7 @@ import Subscription private class DefaultTunnelSessionProvider: TunnelSessionProvider { func activeSession() async -> NETunnelProviderSession? { - try? await ConnectionSessionUtilities.activeSession() + return await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() } } diff --git a/DuckDuckGo/NetworkProtectionDebugUtilities.swift b/DuckDuckGo/NetworkProtectionDebugUtilities.swift index 22a16a2b83..0186856979 100644 --- a/DuckDuckGo/NetworkProtectionDebugUtilities.swift +++ b/DuckDuckGo/NetworkProtectionDebugUtilities.swift @@ -31,7 +31,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Registation Key func expireRegistrationKeyNow() async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } @@ -41,7 +41,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Notifications func sendTestNotificationRequest() async throws { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } @@ -51,7 +51,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Disable VPN func disableConnectOnDemandAndShutDown() async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } @@ -61,7 +61,7 @@ final class NetworkProtectionDebugUtilities { // MARK: - Failure Simulation func triggerSimulation(_ option: NetworkProtectionSimulationOption) async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { return } diff --git a/DuckDuckGo/NetworkProtectionRootView.swift b/DuckDuckGo/NetworkProtectionRootView.swift index bdad605a52..1d23f301a9 100644 --- a/DuckDuckGo/NetworkProtectionRootView.swift +++ b/DuckDuckGo/NetworkProtectionRootView.swift @@ -33,6 +33,7 @@ struct NetworkProtectionRootView: View { statusViewModel = NetworkProtectionStatusViewModel(tunnelController: AppDependencyProvider.shared.networkProtectionTunnelController, settings: AppDependencyProvider.shared.vpnSettings, statusObserver: AppDependencyProvider.shared.connectionObserver, + serverInfoObserver: AppDependencyProvider.shared.serverInfoObserver, locationListRepository: locationListRepository) } var body: some View { diff --git a/DuckDuckGo/NetworkProtectionStatusView.swift b/DuckDuckGo/NetworkProtectionStatusView.swift index 67abeb033a..b98cc348da 100644 --- a/DuckDuckGo/NetworkProtectionStatusView.swift +++ b/DuckDuckGo/NetworkProtectionStatusView.swift @@ -202,11 +202,9 @@ struct NetworkProtectionStatusView: View { @ViewBuilder private func about() -> some View { Section { - if statusModel.shouldShowFAQ { - NavigationLink(UserText.netPVPNSettingsFAQ, destination: LazyView(NetworkProtectionFAQView())) - .daxBodyRegular() - .foregroundColor(.init(designSystemColor: .textPrimary)) - } + NavigationLink(UserText.netPVPNSettingsFAQ, destination: LazyView(NetworkProtectionFAQView())) + .daxBodyRegular() + .foregroundColor(.init(designSystemColor: .textPrimary)) NavigationLink(UserText.netPVPNSettingsShareFeedback, destination: VPNFeedbackFormCategoryView()) .daxBodyRegular() diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index 2509a21e73..8419921d0d 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -87,7 +87,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { return formatter }() - private let tunnelController: TunnelController + private let tunnelController: (TunnelController & TunnelSessionProvider) private let statusObserver: ConnectionStatusObserver private let serverInfoObserver: ConnectionServerInfoObserver private let errorObserver: ConnectionErrorObserver @@ -134,16 +134,12 @@ final class NetworkProtectionStatusViewModel: ObservableObject { @Published public var downloadTotal: String? private var throughputUpdateTimer: Timer? - var shouldShowFAQ: Bool { - AppDependencyProvider.shared.subscriptionFeatureAvailability.isFeatureAvailable - } - @Published public var animationsOn: Bool = false - public init(tunnelController: TunnelController, + public init(tunnelController: (TunnelController & TunnelSessionProvider), settings: VPNSettings, statusObserver: ConnectionStatusObserver, - serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession(), + serverInfoObserver: ConnectionServerInfoObserver, errorObserver: ConnectionErrorObserver = ConnectionErrorObserverThroughSession(), locationListRepository: NetworkProtectionLocationListRepository) { self.tunnelController = tunnelController @@ -159,6 +155,8 @@ final class NetworkProtectionStatusViewModel: ObservableObject { self.dnsSettings = settings.dnsSettings + updateViewModel(withStatus: statusObserver.recentValue) + setUpIsConnectedStatePublishers() setUpToggledStatePublisher() setUpStatusMessagePublishers() @@ -176,30 +174,10 @@ final class NetworkProtectionStatusViewModel: ObservableObject { } private func setUpIsConnectedStatePublishers() { - let isConnectedPublisher = statusObserver.publisher - .map { $0.isConnected } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - isConnectedPublisher - .map(Self.titleText(connected:)) - .assign(to: \.headerTitle, onWeaklyHeld: self) - .store(in: &cancellables) - isConnectedPublisher - .map(Self.statusImageID(connected:)) - .assign(to: \.statusImageID, onWeaklyHeld: self) - .store(in: &cancellables) - isConnectedPublisher - .sink { [weak self] isConnected in - if !isConnected { - self?.uploadTotal = nil - self?.downloadTotal = nil - self?.throughputUpdateTimer?.invalidate() - self?.throughputUpdateTimer = nil - } else { - self?.setUpThroughputRefreshTimer() - } - } - .store(in: &cancellables) + statusObserver.publisher.sink { [weak self] status in + self?.updateViewModel(withStatus: status) + } + .store(in: &cancellables) } private func setUpToggledStatePublisher() { @@ -292,6 +270,31 @@ final class NetworkProtectionStatusViewModel: ObservableObject { .store(in: &cancellables) } + private func updateViewModel(withStatus connectionStatus: ConnectionStatus) { + self.headerTitle = Self.titleText(connected: connectionStatus.isConnected) + self.statusImageID = Self.statusImageID(connected: connectionStatus.isConnected) + + if !connectionStatus.isConnected { + self.uploadTotal = nil + self.downloadTotal = nil + self.throughputUpdateTimer?.invalidate() + self.throughputUpdateTimer = nil + } else { + self.setUpThroughputRefreshTimer() + } + + switch connectionStatus { + case .connected: + self.isNetPEnabled = true + case .connecting: + self.isNetPEnabled = true + self.resetConnectionInformation() + default: + self.isNetPEnabled = false + self.resetConnectionInformation() + } + } + private func setUpErrorPublishers() { guard AppDependencyProvider.shared.internalUserDecider.isInternalUser else { return @@ -346,7 +349,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { } private func refreshDataVolumeTotals() async { - guard let activeSession = try? await ConnectionSessionUtilities.activeSession() else { + guard let activeSession = await tunnelController.activeSession() else { return } diff --git a/DuckDuckGo/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtectionTunnelController.swift index 02f5953de7..30ef1e6f80 100644 --- a/DuckDuckGo/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtectionTunnelController.swift @@ -33,9 +33,10 @@ enum VPNConfigurationRemovalReason: String { case debugMenu } -final class NetworkProtectionTunnelController: TunnelController { +final class NetworkProtectionTunnelController: TunnelController, TunnelSessionProvider { static var shouldSimulateFailure: Bool = false + private var internalManager: NETunnelProviderManager? private let debugFeatures = NetworkProtectionDebugFeatures() private let tokenStore: NetworkProtectionKeychainTokenStore private let errorStore = NetworkProtectionTunnelErrorStore() @@ -43,6 +44,44 @@ final class NetworkProtectionTunnelController: TunnelController { private var previousStatus: NEVPNStatus = .invalid private var cancellables = Set() + // MARK: - Manager, Session, & Connection + + /// The tunnel manager: will try to load if it its not loaded yet, but if one can't be loaded from preferences, + /// a new one will not be created. This is useful for querying the connection state and information without triggering + /// a VPN-access popup to the user. + /// + @MainActor var tunnelManager: NETunnelProviderManager? { + get async { + if let internalManager { + return internalManager + } + + let loadedManager = try? await NETunnelProviderManager.loadAllFromPreferences().first + internalManager = loadedManager + return loadedManager + } + } + + public var connection: NEVPNConnection? { + get async { + await tunnelManager?.connection + } + } + + public func activeSession() async -> NETunnelProviderSession? { + await session + } + + public var session: NETunnelProviderSession? { + get async { + guard let manager = await tunnelManager, let session = manager.connection as? NETunnelProviderSession else { + return nil + } + + return session + } + } + // MARK: - Starting & Stopping the VPN enum StartError: LocalizedError, CustomNSError { @@ -83,6 +122,7 @@ final class NetworkProtectionTunnelController: TunnelController { init(accountManager: AccountManager, tokenStore: NetworkProtectionKeychainTokenStore) { self.tokenStore = tokenStore subscribeToStatusChanges() + subscribeToConfigurationChanges() } /// Starts the VPN connection used for Network Protection @@ -106,7 +146,7 @@ final class NetworkProtectionTunnelController: TunnelController { } func stop() async { - guard let tunnelManager = await loadTunnelManager() else { + guard let tunnelManager = await self.tunnelManager else { return } @@ -139,8 +179,7 @@ final class NetworkProtectionTunnelController: TunnelController { var isInstalled: Bool { get async { - let tunnelManager = await loadTunnelManager() - return tunnelManager != nil + return await self.tunnelManager != nil } } @@ -150,7 +189,7 @@ final class NetworkProtectionTunnelController: TunnelController { /// var isConnected: Bool { get async { - guard let tunnelManager = await loadTunnelManager() else { + guard let tunnelManager = await self.tunnelManager else { return false } @@ -174,7 +213,7 @@ final class NetworkProtectionTunnelController: TunnelController { switch tunnelManager.connection.status { case .invalid: - reloadTunnelManager() + clearInternalManager() try await startWithError() case .connected: // Intentional no-op @@ -184,10 +223,8 @@ final class NetworkProtectionTunnelController: TunnelController { } } - /// Reloads the tunnel manager from preferences. - /// - private func reloadTunnelManager() { - internalTunnelManager = nil + private func clearInternalManager() { + internalManager = nil } private func start(_ tunnelManager: NETunnelProviderManager) throws { @@ -224,35 +261,11 @@ final class NetworkProtectionTunnelController: TunnelController { } } - /// The actual storage for our tunnel manager. - /// - private var internalTunnelManager: NETunnelProviderManager? - - /// The tunnel manager: will try to load if it its not loaded yet, but if one can't be loaded from preferences, - /// a new one will not be created. This is useful for querying the connection state and information without triggering - /// a VPN-access popup to the user. - /// - private var tunnelManager: NETunnelProviderManager? { - get async { - guard let tunnelManager = internalTunnelManager else { - let tunnelManager = await loadTunnelManager() - internalTunnelManager = tunnelManager - return tunnelManager - } - - return tunnelManager - } - } - - private func loadTunnelManager() async -> NETunnelProviderManager? { - try? await NETunnelProviderManager.loadAllFromPreferences().first - } - private func loadOrMakeTunnelManager() async throws -> NETunnelProviderManager { guard let tunnelManager = await tunnelManager else { let tunnelManager = NETunnelProviderManager() try await setupAndSave(tunnelManager) - internalTunnelManager = tunnelManager + internalManager = tunnelManager return tunnelManager } @@ -262,12 +275,7 @@ final class NetworkProtectionTunnelController: TunnelController { private func setupAndSave(_ tunnelManager: NETunnelProviderManager) async throws { setup(tunnelManager) - try await saveToPreferences(tunnelManager) - try await loadFromPreferences(tunnelManager) - try await saveToPreferences(tunnelManager) - } - private func saveToPreferences(_ tunnelManager: NETunnelProviderManager) async throws { do { try await tunnelManager.saveToPreferences() } catch { @@ -281,9 +289,7 @@ final class NetworkProtectionTunnelController: TunnelController { } throw StartError.saveToPreferencesFailed(error) } - } - private func loadFromPreferences(_ tunnelManager: NETunnelProviderManager) async throws { do { try await tunnelManager.loadFromPreferences() } catch { @@ -311,6 +317,31 @@ final class NetworkProtectionTunnelController: TunnelController { tunnelManager.onDemandRules = [NEOnDemandRuleConnect()] } + // MARK: - Observing Configuration Changes + + private func subscribeToConfigurationChanges() { + notificationCenter.publisher(for: .NEVPNConfigurationChange) + .receive(on: DispatchQueue.main) + .sink { _ in + Task { @MainActor in + guard let manager = self.internalManager else { + return + } + + do { + try await manager.loadFromPreferences() + + if manager.connection.status == .invalid { + self.clearInternalManager() + } + } catch { + self.clearInternalManager() + } + } + } + .store(in: &cancellables) + } + // MARK: - Observing Status Changes private func subscribeToStatusChanges() { diff --git a/DuckDuckGo/SettingsRootView.swift b/DuckDuckGo/SettingsRootView.swift index 32d4599313..415ce3d3bd 100644 --- a/DuckDuckGo/SettingsRootView.swift +++ b/DuckDuckGo/SettingsRootView.swift @@ -117,6 +117,8 @@ struct SettingsRootView: View { SubscriptionContainerViewFactory.makeSubscribeFlow(origin: origin, navigationCoordinator: subscriptionNavigationCoordinator, subscriptionManager: AppDependencyProvider.shared.subscriptionManager) + case .duckPlayer: + SettingsDuckPlayerView().environmentObject(viewModel) default: EmptyView() } diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index f437dd7d4f..a31df34ba0 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -634,6 +634,7 @@ extension SettingsViewModel { case dbp case itr case subscriptionFlow(origin: String? = nil) + case duckPlayer // Add other cases as needed var id: String { @@ -642,6 +643,7 @@ extension SettingsViewModel { case .dbp: return "dbp" case .itr: return "itr" case .subscriptionFlow: return "subscriptionFlow" + case .duckPlayer: return "duckPlayer" // Ensure all cases are covered } } @@ -791,3 +793,8 @@ extension SettingsViewModel { } } + +// Deeplink notification handling +extension NSNotification.Name { + static let settingsDeepLinkNotification: NSNotification.Name = Notification.Name(rawValue: "com.duckduckgo.notification.settingsDeepLink") +} diff --git a/DuckDuckGo/TabManager.swift b/DuckDuckGo/TabManager.swift index d0a73362c6..12ed099455 100644 --- a/DuckDuckGo/TabManager.swift +++ b/DuckDuckGo/TabManager.swift @@ -35,6 +35,7 @@ class TabManager { private let historyManager: HistoryManaging private let syncService: DDGSyncing private var previewsSource: TabPreviewsSource + private var duckPlayerNavigationHandler: DuckNavigationHandling weak var delegate: TabDelegate? @@ -52,6 +53,9 @@ class TabManager { self.bookmarksDatabase = bookmarksDatabase self.historyManager = historyManager self.syncService = syncService + + // Init Duck Player Handler + self.duckPlayerNavigationHandler = DuckPlayerNavigationHandler(duckPlayer: DuckPlayer()) registerForNotifications() } @@ -68,7 +72,8 @@ class TabManager { let controller = TabViewController.loadFromStoryboard(model: tab, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + duckPlayerNavigationHandler: duckPlayerNavigationHandler) controller.applyInheritedAttribution(inheritedAttribution) controller.attachWebView(configuration: configuration, andLoadRequest: url == nil ? nil : URLRequest.userInitiated(url!), @@ -140,7 +145,8 @@ class TabManager { let controller = TabViewController.loadFromStoryboard(model: tab, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + duckPlayerNavigationHandler: duckPlayerNavigationHandler) controller.attachWebView(configuration: configCopy, andLoadRequest: request, consumeCookies: !model.hasActiveTabs, diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index ae3e593273..3717070681 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -294,7 +294,8 @@ class TabViewController: UIViewController { appSettings: AppSettings = AppDependencyProvider.shared.appSettings, bookmarksDatabase: CoreDataDatabase, historyManager: HistoryManaging, - syncService: DDGSyncing) -> TabViewController { + syncService: DDGSyncing, + duckPlayerNavigationHandler: DuckNavigationHandling) -> TabViewController { let storyboard = UIStoryboard(name: "Tab", bundle: nil) let controller = storyboard.instantiateViewController(identifier: "TabViewController", creator: { coder in TabViewController(coder: coder, @@ -302,7 +303,8 @@ class TabViewController: UIViewController { appSettings: appSettings, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + duckPlayerNavigationHandler: duckPlayerNavigationHandler) }) return controller } @@ -313,22 +315,22 @@ class TabViewController: UIViewController { let historyManager: HistoryManaging let historyCapture: HistoryCapture - - var duckPlayer: DuckPlayerProtocol = DuckPlayer() - var youtubeNavigationHandler: DuckNavigationHandling? + var duckPlayerNavigationHandler: DuckNavigationHandling required init?(coder aDecoder: NSCoder, tabModel: Tab, appSettings: AppSettings, bookmarksDatabase: CoreDataDatabase, historyManager: HistoryManaging, - syncService: DDGSyncing) { + syncService: DDGSyncing, + duckPlayerNavigationHandler: DuckNavigationHandling) { self.tabModel = tabModel self.appSettings = appSettings self.bookmarksDatabase = bookmarksDatabase self.historyManager = historyManager self.historyCapture = HistoryCapture(historyManager: historyManager) self.syncService = syncService + self.duckPlayerNavigationHandler = duckPlayerNavigationHandler super.init(coder: aDecoder) } @@ -346,9 +348,6 @@ class TabViewController: UIViewController { subscribeToEmailProtectionSignOutNotification() registerForDownloadsNotifications() registerForAddressBarLocationNotifications() - - // Setup DuckPlayer navigation handler - self.youtubeNavigationHandler = YoutubePlayerNavigationHandler(duckPlayer: duckPlayer) if #available(iOS 16.4, *) { registerForInspectableWebViewNotifications() @@ -681,18 +680,15 @@ class TabViewController: UIViewController { } else if let currentHost = url?.host, let newHost = webView.url?.host, currentHost == newHost { url = webView.url - if let handler = youtubeNavigationHandler, - let url, + if let url, url.isYoutubeVideo, - duckPlayer.settings.mode == .enabled { - handler.handleURLChange(url: url, webView: webView) + duckPlayerNavigationHandler.duckPlayer.settings.mode == .enabled { + duckPlayerNavigationHandler.handleURLChange(url: url, webView: webView) } } - if var handler = youtubeNavigationHandler, - let url { - handler.referrer = url.isYoutube ? .youtube : .other - + if let url { + duckPlayerNavigationHandler.referrer = url.isYoutube ? .youtube : .other } } @@ -744,8 +740,8 @@ class TabViewController: UIViewController { public func reload() { updateContentMode() cachedRuntimeConfigurationForDomain = [:] - if let url = webView.url, url.isDuckPlayer, let handler = youtubeNavigationHandler { - handler.handleReload(webView: webView) + if let url = webView.url, url.isDuckPlayer { + duckPlayerNavigationHandler.handleReload(webView: webView) } else { webView.reload() } @@ -759,8 +755,8 @@ class TabViewController: UIViewController { func goBack() { dismissJSAlertIfNeeded() - if let url = url, url.isDuckPlayer, let handler = youtubeNavigationHandler { - handler.handleGoBack(webView: webView) + if let url = url, url.isDuckPlayer { + duckPlayerNavigationHandler.handleGoBack(webView: webView) chromeDelegate?.omniBar.resignFirstResponder() return } @@ -1677,10 +1673,10 @@ extension TabViewController: WKNavigationDelegate { } if navigationAction.isTargetingMainFrame(), - let handler = youtubeNavigationHandler, url.isYoutubeVideo, - duckPlayer.settings.mode == .enabled { - handler.handleDecidePolicyFor(navigationAction, completion: completion, webView: webView) + duckPlayerNavigationHandler.duckPlayer.settings.mode == .enabled { + duckPlayerNavigationHandler.handleDecidePolicyFor(navigationAction, webView: webView) + completion(.allow) return } @@ -1701,11 +1697,9 @@ extension TabViewController: WKNavigationDelegate { performBlobNavigation(navigationAction, completion: completion) case .duck: - if let handler = youtubeNavigationHandler { - handler.handleNavigation(navigationAction, webView: webView, completion: completion) - return - } + duckPlayerNavigationHandler.handleNavigation(navigationAction, webView: webView) completion(.cancel) + return case .unknown: if navigationAction.navigationType == .linkActivated { @@ -2354,7 +2348,7 @@ extension TabViewController: UserContentControllerDelegate { userScripts.autoconsentUserScript.delegate = self // Setup DuckPlayer - userScripts.duckPlayer = duckPlayer + userScripts.duckPlayer = duckPlayerNavigationHandler.duckPlayer userScripts.youtubeOverlayScript?.webView = webView userScripts.youtubePlayerUserScript?.webView = webView diff --git a/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift b/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift index e6a48170cd..b25fb38e35 100644 --- a/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift @@ -105,7 +105,8 @@ extension TabViewController { let tabController = TabViewController.loadFromStoryboard(model: tab, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + duckPlayerNavigationHandler: duckPlayerNavigationHandler) tabController.isLinkPreview = true let configuration = WKWebViewConfiguration.nonPersistent() tabController.attachWebView(configuration: configuration, andLoadRequest: URLRequest.userInitiated(url), consumeCookies: false) diff --git a/DuckDuckGoTests/DuckPlayerMocks.swift b/DuckDuckGoTests/DuckPlayerMocks.swift index aea76a748a..09d95354a8 100644 --- a/DuckDuckGoTests/DuckPlayerMocks.swift +++ b/DuckDuckGoTests/DuckPlayerMocks.swift @@ -110,6 +110,26 @@ final class MockDuckPlayerSettings: DuckPlayerSettingsProtocol { } final class MockDuckPlayer: DuckPlayerProtocol { + var hostView: UIViewController? + + func openDuckPlayerSettings(params: Any, message: WKScriptMessage) async -> (any Encodable)? { + nil + } + + func openDuckPlayerInfo(params: Any, message: WKScriptMessage) async -> (any Encodable)? { + nil + } + + func setHostViewController(_ vc: UIViewController) {} + + func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> (any Encodable)? { + nil + } + + func initialSetupOverlay(params: Any, message: WKScriptMessage) async -> (any Encodable)? { + nil + } + var settings: any DuckPlayerSettingsProtocol init(settings: DuckPlayerSettingsProtocol) { diff --git a/DuckDuckGoTests/DuckPlayerURLExtensionTests.swift b/DuckDuckGoTests/DuckPlayerURLExtensionTests.swift index d5df8b9603..d45e6ca8f7 100644 --- a/DuckDuckGoTests/DuckPlayerURLExtensionTests.swift +++ b/DuckDuckGoTests/DuckPlayerURLExtensionTests.swift @@ -22,6 +22,12 @@ import os.log @testable import DuckDuckGo final class DuckPlayerURLExtensionTests: XCTestCase { + + #if os(iOS) + let baseUrl = "https://m.youtube.com" + #else + let baseUrl = "https://www.youtube.com" + #endif func testIsDuckPlayerScheme() { XCTAssertTrue("duck:player/abcdef12345".url!.isDuckURLScheme) @@ -29,7 +35,7 @@ final class DuckPlayerURLExtensionTests: XCTestCase { XCTAssertTrue("duck://player/abcdef".url!.isDuckURLScheme) XCTAssertTrue("duck://player/12345".url!.isDuckURLScheme) XCTAssertFalse("http://duckplayer/abcdef12345".url!.isDuckURLScheme) - XCTAssertFalse("https://www.youtube.com/watch?v=abcdef12345".url!.isDuckURLScheme) + XCTAssertFalse("\(baseUrl)/watch?v=abcdef12345".url!.isDuckURLScheme) XCTAssertFalse("https://www.youtube-nocookie.com/embed/abcdef12345".url!.isDuckURLScheme) } @@ -41,25 +47,25 @@ final class DuckPlayerURLExtensionTests: XCTestCase { XCTAssertFalse("https://www.youtube-nocookie.com/embed?t=23s".url!.isDuckPlayer) XCTAssertTrue("duck://player/abcdef12345".url!.isDuckPlayer) - XCTAssertFalse("https://www.youtube.com/watch?v=abcdef12345".url!.isDuckPlayer) + XCTAssertFalse("\(baseUrl)/watch?v=abcdef12345".url!.isDuckPlayer) XCTAssertFalse("https://duckduckgo.com".url!.isDuckPlayer) } func testIsYoutubePlaylist() { - XCTAssertTrue("https://www.youtube.com/watch?v=abcdef12345&list=abcdefgh12345678".url!.isYoutubePlaylist) - XCTAssertTrue("https://www.youtube.com/watch?list=abcdefgh12345678&v=abcdef12345".url!.isYoutubePlaylist) + XCTAssertTrue("\(baseUrl)/watch?v=abcdef12345&list=abcdefgh12345678".url!.isYoutubePlaylist) + XCTAssertTrue("\(baseUrl)/watch?list=abcdefgh12345678&v=abcdef12345".url!.isYoutubePlaylist) XCTAssertFalse("https://duckduckgo.com/watch?v=abcdef12345&list=abcdefgh12345678".url!.isYoutubePlaylist) - XCTAssertFalse("https://www.youtube.com/watch?list=abcdefgh12345678".url!.isYoutubePlaylist) - XCTAssertFalse("https://www.youtube.com/watch?v=abcdef12345&list=abcdefgh12345678&index=1".url!.isYoutubePlaylist) + XCTAssertFalse("\(baseUrl)/watch?list=abcdefgh12345678".url!.isYoutubePlaylist) + XCTAssertFalse("\(baseUrl)/watch?v=abcdef12345&list=abcdefgh12345678&index=1".url!.isYoutubePlaylist) } func testIsYoutubeVideo() { - XCTAssertTrue("https://www.youtube.com/watch?v=abcdef12345".url!.isYoutubeVideo) - XCTAssertTrue("https://www.youtube.com/watch?v=abcdef12345&list=abcdefgh12345678&index=1".url!.isYoutubeVideo) - XCTAssertTrue("https://www.youtube.com/watch?v=abcdef12345&t=5m".url!.isYoutubeVideo) + XCTAssertTrue("\(baseUrl)/watch?v=abcdef12345".url!.isYoutubeVideo) + XCTAssertTrue("\(baseUrl)/watch?v=abcdef12345&list=abcdefgh12345678&index=1".url!.isYoutubeVideo) + XCTAssertTrue("\(baseUrl)/watch?v=abcdef12345&t=5m".url!.isYoutubeVideo) - XCTAssertFalse("https://www.youtube.com/watch?v=abcdef12345&list=abcdefgh12345678".url!.isYoutubeVideo) + XCTAssertFalse("\(baseUrl)/watch?v=abcdef12345&list=abcdefgh12345678".url!.isYoutubeVideo) XCTAssertFalse("https://duckduckgo.com/watch?v=abcdef12345".url!.isYoutubeVideo) } @@ -74,15 +80,15 @@ final class DuckPlayerURLExtensionTests: XCTestCase { } func testYoutubeVideoParamsFromYoutubeURL() { - let params = "https://www.youtube.com/watch?v=abcdef12345".url!.youtubeVideoParams + let params = "\(baseUrl)/watch?v=abcdef12345".url!.youtubeVideoParams XCTAssertEqual(params?.videoID, "abcdef12345") XCTAssertEqual(params?.timestamp, nil) - let paramsWithTimestamp = "https://www.youtube.com/watch?v=abcdef12345&t=23s".url!.youtubeVideoParams + let paramsWithTimestamp = "\(baseUrl)/watch?v=abcdef12345&t=23s".url!.youtubeVideoParams XCTAssertEqual(paramsWithTimestamp?.videoID, "abcdef12345") XCTAssertEqual(paramsWithTimestamp?.timestamp, "23s") - let paramsWithTimestampWithoutUnits = "https://www.youtube.com/watch?t=102&v=abcdef12345&feature=youtu.be".url!.youtubeVideoParams + let paramsWithTimestampWithoutUnits = "\(baseUrl)/watch?t=102&v=abcdef12345&feature=youtu.be".url!.youtubeVideoParams XCTAssertEqual(paramsWithTimestampWithoutUnits?.videoID, "abcdef12345") XCTAssertEqual(paramsWithTimestampWithoutUnits?.timestamp, "102") } @@ -110,15 +116,15 @@ final class DuckPlayerURLExtensionTests: XCTestCase { } func testYoutubeURLTimestampValidation() { - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: nil).absoluteString, "https://www.youtube.com/watch?v=abcdef12345") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "23s").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=23s") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5m5s").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=5m5s") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "12h400m100s").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=12h400m100s") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "12h2s2h").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=12h2s2h") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5m5m5m").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=5m5m5m") - - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5").absoluteString, "https://www.youtube.com/watch?v=abcdef12345&t=5") - XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "10d").absoluteString, "https://www.youtube.com/watch?v=abcdef12345") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: nil).absoluteString, "\(baseUrl)/watch?v=abcdef12345") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "23s").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=23s") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5m5s").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=5m5s") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "12h400m100s").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=12h400m100s") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "12h2s2h").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=12h2s2h") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5m5m5m").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=5m5m5m") + + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "5").absoluteString, "\(baseUrl)/watch?v=abcdef12345&t=5") + XCTAssertEqual(URL.youtube("abcdef12345", timestamp: "10d").absoluteString, "\(baseUrl)/watch?v=abcdef12345") } func testYoutubeNoCookieURLTimestampValidation() { diff --git a/DuckDuckGoTests/MockDependencyProvider.swift b/DuckDuckGoTests/MockDependencyProvider.swift index 7a0a327087..214f675820 100644 --- a/DuckDuckGoTests/MockDependencyProvider.swift +++ b/DuckDuckGoTests/MockDependencyProvider.swift @@ -46,6 +46,7 @@ class MockDependencyProvider: DependencyProvider { var networkProtectionKeychainTokenStore: NetworkProtectionKeychainTokenStore var networkProtectionTunnelController: NetworkProtectionTunnelController var connectionObserver: NetworkProtection.ConnectionStatusObserver + var serverInfoObserver: NetworkProtection.ConnectionServerInfoObserver var vpnSettings: NetworkProtection.VPNSettings init() { @@ -88,6 +89,7 @@ class MockDependencyProvider: DependencyProvider { accountManager: accountManager) connectionObserver = ConnectionStatusObserverThroughSession() + serverInfoObserver = ConnectionServerInfoObserverThroughSession() vpnSettings = VPNSettings(defaults: .networkProtectionGroupDefaults) } } diff --git a/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift b/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift index fe4f76eb09..877980dc8e 100644 --- a/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift +++ b/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift @@ -25,14 +25,14 @@ import BrowserServicesKit @testable import DuckDuckGo -class YoutubePlayerNavigationHandlerTests: XCTestCase { +class DuckPlayerNavigationHandlerTests: XCTestCase { var webView: WKWebView! var mockWebView: MockWebView! var mockNavigationDelegate: MockWKNavigationDelegate! var mockAppSettings: AppSettingsMock! var mockPrivacyConfig: PrivacyConfigurationManagerMock! - + override func setUp() { super.setUp() webView = WKWebView() @@ -52,7 +52,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { // Test for htmlTemplatePath existence func testHtmlTemplatePathExists() { - let templatePath = YoutubePlayerNavigationHandler.htmlTemplatePath + let templatePath = DuckPlayerNavigationHandler.htmlTemplatePath let fileExists = FileManager.default.fileExists(atPath: templatePath) XCTAssertFalse(templatePath.isEmpty, "The template path should not be empty") XCTAssertTrue(fileExists, "The template file should exist at the specified path") @@ -62,7 +62,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { func testMakeDuckPlayerRequestFromOriginalRequest() { let originalRequest = URLRequest(url: URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")!) - let duckPlayerRequest = YoutubePlayerNavigationHandler.makeDuckPlayerRequest(from: originalRequest) + let duckPlayerRequest = DuckPlayerNavigationHandler.makeDuckPlayerRequest(from: originalRequest) XCTAssertEqual(duckPlayerRequest.url?.host, "www.youtube-nocookie.com") XCTAssertEqual(duckPlayerRequest.url?.path, "/embed/abc123") @@ -76,7 +76,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { let videoID = "abc123" let timestamp = "10s" - let duckPlayerRequest = YoutubePlayerNavigationHandler.makeDuckPlayerRequest(for: videoID, timestamp: timestamp) + let duckPlayerRequest = DuckPlayerNavigationHandler.makeDuckPlayerRequest(for: videoID, timestamp: timestamp) XCTAssertEqual(duckPlayerRequest.url?.host, "www.youtube-nocookie.com") XCTAssertEqual(duckPlayerRequest.url?.path, "/embed/abc123") @@ -87,24 +87,23 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { // Test for makeHTMLFromTemplate func testMakeHTMLFromTemplate() { - let expectedHtml = try? String(contentsOfFile: YoutubePlayerNavigationHandler.htmlTemplatePath) - let html = YoutubePlayerNavigationHandler.makeHTMLFromTemplate() + let expectedHtml = try? String(contentsOfFile: DuckPlayerNavigationHandler.htmlTemplatePath) + let html = DuckPlayerNavigationHandler.makeHTMLFromTemplate() XCTAssertEqual(html, expectedHtml) } - // Test for handleURLChange + // MARK: handleURLChange tests @MainActor func testHandleURLChangeDuckPlayerEnabled() { let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.alwaysAsk) + playerSettings.setMode(.enabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleURLChange(url: youtubeURL, webView: mockWebView) - XCTAssertTrue(mockWebView.didStopLoadingCalled, "Expected stopLoading to be called") XCTAssertNotNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") if let loadedRequest = mockWebView.lastLoadedRequest { @@ -113,6 +112,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { XCTAssertEqual(loadedRequest.url?.path, "/abc123") XCTAssertEqual(loadedRequest.url?.query?.contains("t=10s"), true) } + } @MainActor @@ -122,157 +122,147 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.setMode(.disabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleURLChange(url: youtubeURL, webView: mockWebView) - XCTAssertFalse(mockWebView.didStopLoadingCalled, "Expected stopLoading Not to be called") - XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") + XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request NOT to be loaded") } @MainActor - func testHandleURLChangeForNonYouTubeVideo() { + func testHandleURLChangeDuckPlayerTemporarilyDisabled() { + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! + + let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + playerSettings.setMode(.enabled) + let player = MockDuckPlayer(settings: playerSettings) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) + + handler.isDuckPlayerTemporarilyDisabled = true + + handler.handleURLChange(url: youtubeURL, webView: mockWebView) + + XCTAssertNotNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") + + if let loadedRequest = mockWebView.lastLoadedRequest { + XCTAssertEqual(loadedRequest.url?.scheme, "https") + XCTAssertEqual(loadedRequest.url?.host, "m.youtube.com") + XCTAssertEqual(loadedRequest.url?.path, "/watch") + XCTAssertEqual(loadedRequest.url?.query?.contains("v=abc123"), true) + XCTAssertEqual(loadedRequest.url?.query?.contains("t=10s"), true) + } + } + + @MainActor + func testHandleURLChangeNonYouTubeURL() { let nonYouTubeURL = URL(string: "https://www.google.com")! let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.disabled) + playerSettings.setMode(.enabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleURLChange(url: nonYouTubeURL, webView: mockWebView) - XCTAssertFalse(mockWebView.didStopLoadingCalled, "Expected stopLoading not to be called") - XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") + XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request NOT to be loaded") } - // Test for handleDecidePolicyFor @MainActor - func testHandleDecidePolicyForWithDuckPlayerEnabled() { - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") - - var navigationPolicy: WKNavigationActionPolicy? + func testHandleNavigationOpenInYoutubeLink() { + let duckURL = URL(string: "duck://player/openInYoutube?v=12345")! + let navigationAction = MockNavigationAction(request: URLRequest(url: duckURL)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.alwaysAsk) + playerSettings.setMode(.enabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) - - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy - expectation.fulfill() - }, webView: mockWebView) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) - waitForExpectations(timeout: 1, handler: nil) + handler.handleNavigation(navigationAction, webView: mockWebView) - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") + XCTAssertTrue(handler.isDuckPlayerTemporarilyDisabled, "Expected DuckPlayer to be temporarily disabled") XCTAssertNotNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") if let loadedRequest = mockWebView.lastLoadedRequest { - XCTAssertEqual(loadedRequest.url?.scheme, "duck") - XCTAssertEqual(loadedRequest.url?.host, "player") - XCTAssertEqual(loadedRequest.url?.path, "/abc123") - XCTAssertEqual(loadedRequest.url?.query?.contains("t=10s"), true) + XCTAssertEqual(loadedRequest.url?.scheme, "https") + XCTAssertEqual(loadedRequest.url?.host, "m.youtube.com") + XCTAssertEqual(loadedRequest.url?.path, "/watch") + XCTAssertEqual(loadedRequest.url?.query?.contains("v=12345"), true) } - } @MainActor - func testHandleDecidePolicyForWithDuckPlayerDisabled() { - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") - - var navigationPolicy: WKNavigationActionPolicy? + func testHandleNavigationDuckPlayerEnabledAlreadyInDuckPlayer() { + let duckPlayerURL = URL(string: "duck://player/CYTASDSD")! + let navigationAction = MockNavigationAction(request: URLRequest(url: duckPlayerURL)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.disabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + player.settings.setMode(.enabled) - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy - expectation.fulfill() - }, webView: mockWebView) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) + handler.handleNavigation(navigationAction, webView: mockWebView) - waitForExpectations(timeout: 1, handler: nil) - - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") - + } + @MainActor - func testHandleDecidePolicyForNonYouTubeVideoWithDuckPlayerEnabled() { - let nonYouTubeURL = URL(string: "https://www.google.com")! - let navigationAction = MockNavigationAction(request: URLRequest(url: nonYouTubeURL)) - let expectation = self.expectation(description: "Completion handler called") - - var navigationPolicy: WKNavigationActionPolicy? + func testHandleNavigationDuckPlayerDisabled() { + let duckPlayerURL = URL(string: "duck://player/CUIUIIUI")! + let navigationAction = MockNavigationAction(request: URLRequest(url: duckPlayerURL)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.alwaysAsk) + playerSettings.setMode(.disabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) - - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy - expectation.fulfill() - }, webView: mockWebView) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) + + handler.handleNavigation(navigationAction, webView: mockWebView) - waitForExpectations(timeout: 1, handler: nil) + XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") - XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") } @MainActor - func testHandleDecidePolicyForNonYouTubeVideoWithDuckPlayerDisabled() { - let nonYouTubeURL = URL(string: "https://www.google.com")! - let navigationAction = MockNavigationAction(request: URLRequest(url: nonYouTubeURL)) - let expectation = self.expectation(description: "Completion handler called") - - var navigationPolicy: WKNavigationActionPolicy? - + func testHandleDecidePolicyForVideoJustHandled() { + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! + let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.disabled) + playerSettings.setMode(.enabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) - - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy + let handler = DuckPlayerNavigationHandler(duckPlayer: player) + + // Call handleDecidePolicyFor twice with the same URL to simulate handling the same video twice + handler.handleDecidePolicyFor(navigationAction, webView: mockWebView) + + // Wait for 0.8 seconds to simulate the time delay + let expectation = self.expectation(description: "Wait for 0.8 seconds") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + handler.handleDecidePolicyFor(navigationAction, webView: self.mockWebView) expectation.fulfill() - }, webView: mockWebView) + } - waitForExpectations(timeout: 1, handler: nil) + waitForExpectations(timeout: 1.0, handler: nil) - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") - XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") + // Verify that the second call did not load a new request + XCTAssertNil(mockWebView.lastLoadedRequest, "Expected no new request to be loaded since video was just handled") } @MainActor - func testHandleReloadForDuckPlayerVideoWithDuckPlayerEnabled() { - let duckPlayerURL = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! - - mockWebView.setCurrentURL(duckPlayerURL) + func testHandleDecidePolicyForTransformYoutubeURL() { + let youtubeURL = URL(string: "https://m.youtube.com/watch?v=abc123&t=10s")! + let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.setMode(.alwaysAsk) + playerSettings.setMode(.enabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) - - handler.handleReload(webView: mockWebView) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) - XCTAssertNotNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") + handler.handleDecidePolicyFor(navigationAction, webView: mockWebView) - if let loadedRequest = mockWebView.lastLoadedRequest { - XCTAssertEqual(loadedRequest.url?.scheme, "duck") - XCTAssertEqual(loadedRequest.url?.host, "player") - XCTAssertEqual(loadedRequest.url?.path, "/abc123") - XCTAssertEqual(loadedRequest.url?.query?.contains("t=10s"), true) - } + XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") + } @MainActor @@ -284,14 +274,14 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.setMode(.disabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleReload(webView: mockWebView) XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") } - + @MainActor func testHandleReloadForNonDuckPlayerVideoWithDuckPlayerEnabled() { let nonDuckPlayerURL = URL(string: "https://www.google.com")! @@ -302,7 +292,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.setMode(.alwaysAsk) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleReload(webView: mockWebView) XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") @@ -318,10 +308,10 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.setMode(.disabled) let player = MockDuckPlayer(settings: playerSettings) - let handler = YoutubePlayerNavigationHandler(duckPlayer: player) + let handler = DuckPlayerNavigationHandler(duckPlayer: player) handler.handleReload(webView: mockWebView) XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") } - + } diff --git a/submodules/privacy-reference-tests b/submodules/privacy-reference-tests index afb4f6128a..a603ff9af2 160000 --- a/submodules/privacy-reference-tests +++ b/submodules/privacy-reference-tests @@ -1 +1 @@ -Subproject commit afb4f6128a3b50d53ddcb1897ea1fb4df6858aa1 +Subproject commit a603ff9af22ca3ff7ce2e7ffbfe18c447d9f23e8