diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index ad4d2a7a02..47b48f1333 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -710,8 +710,8 @@ extension Pixel { case privacyProKeychainAccessError case privacyProSubscriptionCookieMissingTokenOnSignIn case privacyProSubscriptionCookieMissingCookieOnSignOut - case privacyProSubscriptionCookieRefreshedWithUpdate - case privacyProSubscriptionCookieRefreshedWithDelete + case privacyProSubscriptionCookieRefreshedWithAccessToken + case privacyProSubscriptionCookieRefreshedWithEmptyValue case privacyProSubscriptionCookieFailedToSetSubscriptionCookie // MARK: Pixel Experiment @@ -799,6 +799,10 @@ extension Pixel { case duckPlayerSettingAlwaysSettings case duckPlayerSettingNeverSettings case duckPlayerSettingBackToDefault + case duckPlayerSettingsAlwaysOverlaySERP + case duckPlayerSettingsAlwaysOverlayYoutube + case duckPlayerSettingsNeverOverlaySERP + case duckPlayerSettingsNeverOverlayYoutube case duckPlayerWatchOnYoutube case duckPlayerSettingAlwaysOverlayYoutube case duckPlayerSettingNeverOverlayYoutube @@ -826,13 +830,6 @@ extension Pixel { case pproFeedbackSubcategoryScreenShow(source: String, reportType: String, category: String) case pproFeedbackSubmitScreenShow(source: String, reportType: String, category: String, subcategory: String) case pproFeedbackSubmitScreenFAQClick(source: String, reportType: String, category: String, subcategory: String) - - // MARK: DuckPlayer Pixel Experiment - case duckplayerExperimentCohortAssign - case duckplayerExperimentSearch - case duckplayerExperimentDailySearch - case duckplayerExperimentWeeklySearch - case duckplayerExperimentYoutubePageView // MARK: WebView Error Page Shown case webViewErrorPageShown @@ -1528,8 +1525,8 @@ extension Pixel.Event { case .privacyProKeychainAccessError: return "m_privacy-pro_keychain_access_error" case .privacyProSubscriptionCookieMissingTokenOnSignIn: return "m_privacy-pro_subscription-cookie-missing_token_on_sign_in" case .privacyProSubscriptionCookieMissingCookieOnSignOut: return "m_privacy-pro_subscription-cookie-missing_cookie_on_sign_out" - case .privacyProSubscriptionCookieRefreshedWithUpdate: return "m_privacy-pro_subscription-cookie-refreshed_with_update" - case .privacyProSubscriptionCookieRefreshedWithDelete: return "m_privacy-pro_subscription-cookie-refreshed_with_delete" + case .privacyProSubscriptionCookieRefreshedWithAccessToken: return "m_privacy-pro_subscription-cookie-refreshed_with_access_token" + case .privacyProSubscriptionCookieRefreshedWithEmptyValue: return "m_privacy-pro_subscription-cookie-refreshed_with_empty_value" case .privacyProSubscriptionCookieFailedToSetSubscriptionCookie: return "m_privacy-pro_subscription-cookie-failed_to_set_subscription_cookie" // MARK: Pixel Experiment @@ -1623,6 +1620,10 @@ extension Pixel.Event { case .duckPlayerViewFromOther: return "duckplayer_view-from_other" case .duckPlayerSettingAlwaysSettings: return "duckplayer_setting_always_settings" case .duckPlayerSettingAlwaysDuckPlayer: return "duckplayer_setting_always_duck-player" + case .duckPlayerSettingsAlwaysOverlaySERP: return "duckplayer_setting_always_overlay_serp" + case .duckPlayerSettingsAlwaysOverlayYoutube: return "duckplayer_setting_always_overlay_youtube" + case .duckPlayerSettingsNeverOverlaySERP: return "duckplayer_setting_never_overlay_serp" + case .duckPlayerSettingsNeverOverlayYoutube: return "duckplayer_setting_never_overlay_youtube" case .duckPlayerOverlayYoutubeImpressions: return "duckplayer_overlay_youtube_impressions" case .duckPlayerOverlayYoutubeWatchHere: return "duckplayer_overlay_youtube_watch_here" case .duckPlayerSettingNeverSettings: return "duckplayer_setting_never_settings" @@ -1656,13 +1657,6 @@ extension Pixel.Event { case .pproFeedbackSubmitScreenShow: return "m_ppro_feedback_submit-screen_show" case .pproFeedbackSubmitScreenFAQClick: return "m_ppro_feedback_submit-screen-faq_click" - // MARK: Duckplayer experiment - case .duckplayerExperimentCohortAssign: return "duckplayer_experiment_cohort_assign_v2" - case .duckplayerExperimentSearch: return "duckplayer_experiment_search_v2" - case .duckplayerExperimentDailySearch: return "duckplayer_experiment_daily_search_v2" - case .duckplayerExperimentWeeklySearch: return "duckplayer_experiment_weekly_search_v2" - case .duckplayerExperimentYoutubePageView: return "duckplayer_experiment_youtube_page_view_v2" - // MARK: - WebView Error Page shown case .webViewErrorPageShown: return "m_errorpageshown" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index ba25d0db7c..41a0b2d418 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -968,7 +968,6 @@ D60170BD2BA34CE8001911B5 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60170BB2BA32DD6001911B5 /* Subscription.swift */; }; D6037E692C32F2E7009AAEC0 /* DuckPlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6037E682C32F2E7009AAEC0 /* DuckPlayerSettings.swift */; }; D60B1F272B9DDE5A00AE4760 /* SubscriptionGoogleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */; }; - D60E5C2F2C862297007D6BC7 /* DuckPlayerLaunchExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E5C2E2C862297007D6BC7 /* DuckPlayerLaunchExperiment.swift */; }; D61CDA162B7CF77300A0FBB9 /* Subscription in Frameworks */ = {isa = PBXBuildFile; productRef = D61CDA152B7CF77300A0FBB9 /* Subscription */; }; D61CDA182B7CF78300A0FBB9 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = D61CDA172B7CF78300A0FBB9 /* ZIPFoundation */; }; D625AAEC2BBEF27600BC189A /* TabURLInterceptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D625AAEA2BBEEFC900BC189A /* TabURLInterceptorTests.swift */; }; @@ -1024,7 +1023,6 @@ D6E83C602B22B3C9006C8AFB /* SettingsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E83C5F2B22B3C9006C8AFB /* SettingsState.swift */; }; D6E83C662B23936F006C8AFB /* SettingsDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E83C652B23936F006C8AFB /* SettingsDebugView.swift */; }; D6E83C682B23B6A3006C8AFB /* FontSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E83C672B23B6A3006C8AFB /* FontSettings.swift */; }; - D6F557BA2C8859040034444B /* DuckPlayerExperimentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F557B92C8859040034444B /* DuckPlayerExperimentTests.swift */; }; D6F93E3C2B4FFA97004C268D /* SubscriptionDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F93E3B2B4FFA97004C268D /* SubscriptionDebugViewController.swift */; }; D6F93E3E2B50A8A0004C268D /* SubscriptionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F93E3D2B50A8A0004C268D /* SubscriptionSettingsView.swift */; }; D6FEB8B12B7498A300C3615F /* HeadlessWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FEB8B02B7498A300C3615F /* HeadlessWebView.swift */; }; @@ -2779,7 +2777,6 @@ D60170BB2BA32DD6001911B5 /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; D6037E682C32F2E7009AAEC0 /* DuckPlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerSettings.swift; sourceTree = ""; }; D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionGoogleView.swift; sourceTree = ""; }; - D60E5C2E2C862297007D6BC7 /* DuckPlayerLaunchExperiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerLaunchExperiment.swift; sourceTree = ""; }; D625AAEA2BBEEFC900BC189A /* TabURLInterceptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabURLInterceptorTests.swift; sourceTree = ""; }; D62EC3B82C246A5600FC9D04 /* YoutublePlayerNavigationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YoutublePlayerNavigationHandlerTests.swift; sourceTree = ""; }; D62EC3BB2C2470E000FC9D04 /* DuckPlayerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerTests.swift; sourceTree = ""; }; @@ -2831,7 +2828,6 @@ D6E83C5F2B22B3C9006C8AFB /* SettingsState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsState.swift; sourceTree = ""; }; D6E83C652B23936F006C8AFB /* SettingsDebugView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsDebugView.swift; sourceTree = ""; }; D6E83C672B23B6A3006C8AFB /* FontSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontSettings.swift; sourceTree = ""; }; - D6F557B92C8859040034444B /* DuckPlayerExperimentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerExperimentTests.swift; sourceTree = ""; }; D6F93E3B2B4FFA97004C268D /* SubscriptionDebugViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionDebugViewController.swift; sourceTree = ""; }; D6F93E3D2B50A8A0004C268D /* SubscriptionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionSettingsView.swift; sourceTree = ""; }; D6FEB8B02B7498A300C3615F /* HeadlessWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessWebView.swift; sourceTree = ""; }; @@ -5335,7 +5331,6 @@ D6B67A112C332B6E002122EB /* DuckPlayerMocks.swift */, D62EC3BB2C2470E000FC9D04 /* DuckPlayerTests.swift */, D62EC3B82C246A5600FC9D04 /* YoutublePlayerNavigationHandlerTests.swift */, - D6F557B92C8859040034444B /* DuckPlayerExperimentTests.swift */, ); name = DuckPlayer; sourceTree = ""; @@ -5352,7 +5347,6 @@ D63FF8942C1B67E8006DE24D /* YoutubeOverlayUserScript.swift */, D63FF8932C1B67E8006DE24D /* YoutubePlayerUserScript.swift */, 31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */, - D60E5C2E2C862297007D6BC7 /* DuckPlayerLaunchExperiment.swift */, ); path = DuckPlayer; sourceTree = ""; @@ -7834,7 +7828,6 @@ F4F6DFB826EA9AA600ED7E12 /* BookmarksTextFieldCell.swift in Sources */, 6FE127402C204D9B00EB5724 /* ShortcutsView.swift in Sources */, 85F98F92296F32BD00742F4A /* SyncSettingsViewController.swift in Sources */, - D60E5C2F2C862297007D6BC7 /* DuckPlayerLaunchExperiment.swift in Sources */, 84E341961E2F7EFB00BDBA6F /* AppDelegate.swift in Sources */, 310D091D2799F57200DC0060 /* Download.swift in Sources */, BDE91CDA2C62A70B0005CB74 /* UnifiedMetadataCollector.swift in Sources */, @@ -7981,7 +7974,6 @@ F1BDDBFD2C340D9C00459306 /* SubscriptionContainerViewModelTests.swift in Sources */, 987243142C5232B5007ECC76 /* BookmarksDatabaseSetupTests.swift in Sources */, CBDD5DDF29A6736A00832877 /* APIHeadersTests.swift in Sources */, - D6F557BA2C8859040034444B /* DuckPlayerExperimentTests.swift in Sources */, 986B45D0299E30A50089D2D7 /* BookmarkEntityTests.swift in Sources */, 981C49B02C8FA61D00DF11E8 /* DataStoreIdManagerTests.swift in Sources */, B6AD9E3828D4512E0019CDE9 /* EmbeddedTrackerDataTests.swift in Sources */, @@ -9193,7 +9185,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -9230,7 +9222,7 @@ CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9320,7 +9312,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9347,7 +9339,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9496,7 +9488,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9521,7 +9513,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; INFOPLIST_FILE = DuckDuckGo/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9590,7 +9582,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -9624,7 +9616,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9657,7 +9649,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9687,7 +9679,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9997,7 +9989,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10028,7 +10020,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10056,7 +10048,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10089,7 +10081,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -10119,7 +10111,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProviderAlpha.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -10152,11 +10144,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10389,7 +10381,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10416,7 +10408,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10448,7 +10440,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10485,7 +10477,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10520,7 +10512,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10555,11 +10547,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10732,11 +10724,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10765,10 +10757,10 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 2; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index d5f34b1a3c..b5448d7b67 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -89,6 +89,7 @@ import os.log private(set) var subscriptionFeatureAvailability: SubscriptionFeatureAvailability! private var subscriptionCookieManager: SubscriptionCookieManaging! + private var subscriptionCookieManagerFeatureFlagCancellable: AnyCancellable? var privacyProDataReporter: PrivacyProDataReporting! // MARK: - Feature specific app event handlers @@ -123,7 +124,7 @@ import os.log } } - // swiftlint:disable:next function_body_length cyclomatic_complexity + // swiftlint:disable:next function_body_length func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { #if targetEnvironment(simulator) @@ -313,17 +314,8 @@ import os.log subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability( privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, purchasePlatform: .appStore) - - subscriptionCookieManager = SubscriptionCookieManager(subscriptionManager: AppDependencyProvider.shared.subscriptionManager, - currentCookieStore: { [weak self] in - guard self?.mainViewController?.tabManager.model.hasActiveTabs ?? false else { - // We shouldn't interact with WebKit's cookie store unless we have a WebView, - // eventually the subscription cookie will be refreshed on opening the first tab - return nil - } - - return WKWebsiteDataStore.current().httpCookieStore - }, eventMapping: SubscriptionCookieManageEventPixelMapping()) + + subscriptionCookieManager = makeSubscriptionCookieManager() homePageConfiguration = HomePageConfiguration(variantManager: AppDependencyProvider.shared.variantManager, remoteMessagingClient: remoteMessagingClient, @@ -416,6 +408,46 @@ import os.log return true } + private func makeSubscriptionCookieManager() -> SubscriptionCookieManaging { + let subscriptionCookieManager = SubscriptionCookieManager(subscriptionManager: AppDependencyProvider.shared.subscriptionManager, + currentCookieStore: { [weak self] in + guard self?.mainViewController?.tabManager.model.hasActiveTabs ?? false else { + // We shouldn't interact with WebKit's cookie store unless we have a WebView, + // eventually the subscription cookie will be refreshed on opening the first tab + return nil + } + + return WKWebsiteDataStore.current().httpCookieStore + }, eventMapping: SubscriptionCookieManageEventPixelMapping()) + + + let privacyConfigurationManager = ContentBlocking.shared.privacyConfigurationManager + + // Enable subscriptionCookieManager if feature flag is present + if privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) { + subscriptionCookieManager.enableSettingSubscriptionCookie() + } + + // Keep track of feature flag changes + subscriptionCookieManagerFeatureFlagCancellable = privacyConfigurationManager.updatesPublisher + .sink { [weak self, weak privacyConfigurationManager] in + guard let self, let privacyConfigurationManager else { return } + + let isEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) + + Task { [weak self] in + if isEnabled { + self?.subscriptionCookieManager.enableSettingSubscriptionCookie() + await self?.subscriptionCookieManager.refreshSubscriptionCookie() + } else { + await self?.subscriptionCookieManager.disableSettingSubscriptionCookie() + } + } + } + + return subscriptionCookieManager + } + private func makeHistoryManager() -> HistoryManaging { let provider = AppDependencyProvider.shared diff --git a/DuckDuckGo/BarsAnimator.swift b/DuckDuckGo/BarsAnimator.swift index 01cae59724..c52e7201d7 100644 --- a/DuckDuckGo/BarsAnimator.swift +++ b/DuckDuckGo/BarsAnimator.swift @@ -87,18 +87,38 @@ class BarsAnimator { } private func transitioningAndScrolling(in scrollView: UIScrollView) { - let ratio = calculateTransitionRatio(for: scrollView.contentOffset.y) + + // On iOS 18 we end up in a loop after setBarsVisibility. + // It seems to trigger a new didScrollEvent when rendering some PDF files + // That causes an infinite loop. + // Are viewDidScroll calls happening more often for PDF's on iOS 18? + // Adding a debouncer while we investigate further + // https://app.asana.com/0/1204099484721401/1208671955053442/f + let debounceDelay: TimeInterval = 0.01 + struct Debounce { + static var workItem: DispatchWorkItem? + } + Debounce.workItem?.cancel() - if ratio == 1.0 { - barsState = .hidden - } else if ratio == 0 { - barsState = .revealed - } else if transitionProgress == ratio { - return + Debounce.workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + + let ratio = self.calculateTransitionRatio(for: scrollView.contentOffset.y) + + if ratio == 1.0 { + self.barsState = .hidden + } else if ratio == 0 { + self.barsState = .revealed + } else if self.transitionProgress == ratio { + return + } + + self.delegate?.setBarsVisibility(1.0 - ratio, animated: false) + self.transitionProgress = ratio } - delegate?.setBarsVisibility(1.0 - ratio, animated: false) - transitionProgress = ratio + // Schedule the work item + DispatchQueue.main.asyncAfter(deadline: .now() + debounceDelay, execute: Debounce.workItem!) } private func hiddenAndScrolling(in scrollView: UIScrollView) { diff --git a/DuckDuckGo/Debug.storyboard b/DuckDuckGo/Debug.storyboard index 6f8d490805..ebc359a9e2 100644 --- a/DuckDuckGo/Debug.storyboard +++ b/DuckDuckGo/Debug.storyboard @@ -374,15 +374,6 @@ - - - - - - - - - @@ -392,24 +383,6 @@ - - - - - - - - - - - - - - - - - - @@ -759,7 +732,6 @@ - diff --git a/DuckDuckGo/DuckPlayer/DuckPlayer.swift b/DuckDuckGo/DuckPlayer/DuckPlayer.swift index b511962c39..486688dce2 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayer.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayer.swift @@ -26,7 +26,7 @@ import UserScript import Core import ContentScopeScripts -/// Values that the Frontend can use to determine the current state. +/// Values that the frontend can use to determine the current state. struct InitialPlayerSettings: Codable { struct PlayerSettings: Codable { let pip: PIP @@ -58,7 +58,7 @@ struct InitialPlayerSettings: Codable { let localeStrings: String? } -/// Values that the Frontend can use to determine user settings +/// Values that the frontend can use to determine user settings. public struct UserValues: Codable { enum CodingKeys: String, CodingKey { case duckPlayerMode = "privatePlayerMode" @@ -68,6 +68,7 @@ public struct UserValues: Codable { let askModeOverlayHidden: Bool } +/// UI-related values for the frontend. public struct UIValues: Codable { enum CodingKeys: String, CodingKey { case allowFirstVideo @@ -75,23 +76,6 @@ public struct UIValues: Codable { let allowFirstVideo: Bool } -public enum DuckPlayerReferrer { - case youtube, other, serp - - // Computed property to get string values - var stringValue: String { - switch self { - case .youtube: - return "youtube" - case .serp: - return "serp" - default: - return "other" - - } - } -} - // Wrapper to allow sibling properties on each event in the future. struct TelemetryEvent: Decodable { let attributes: Attributes @@ -136,27 +120,94 @@ enum Attributes: Decodable { } } -protocol DuckPlayerProtocol: AnyObject { + +/// Protocol defining the Duck Player functionality. +protocol DuckPlayerControlling: AnyObject { + /// The current Duck Player settings. var settings: DuckPlayerSettings { get } + + /// The host view controller, if any. var hostView: UIViewController? { get } + /// Initializes a new instance of DuckPlayer with the provided settings and feature flagger. + /// + /// - Parameters: + /// - settings: The Duck Player settings. + /// - featureFlagger: The feature flag manager. init(settings: DuckPlayerSettings, featureFlagger: FeatureFlagger) + /// Sets user values received from the web content. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. func setUserValues(params: Any, message: WKScriptMessage) -> Encodable? + + /// Retrieves user values to send to the web content. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. func getUserValues(params: Any, message: WKScriptMessage) -> Encodable? + + /// Opens a video in Duck Player within the specified web view. + /// + /// - Parameters: + /// - url: The URL of the video. + /// - webView: The web view to load the video in. func openVideoInDuckPlayer(url: URL, webView: WKWebView) + + /// Opens Duck Player settings. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. func openDuckPlayerSettings(params: Any, message: WKScriptMessage) async -> Encodable? + + /// Opens Duck Player information modal. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. func openDuckPlayerInfo(params: Any, message: WKScriptMessage) async -> Encodable? + + /// Sends a telemetry event from the FE. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. func telemetryEvent(params: Any, message: WKScriptMessage) async -> Encodable? - + + /// Performs initial setup for the player. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> Encodable? + + /// Performs initial setup for the overlay. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. func initialSetupOverlay(params: Any, message: WKScriptMessage) async -> Encodable? + /// Sets the host view controller for presenting modals. + /// + /// - Parameter vc: The view controller to set as host. func setHostViewController(_ vc: UIViewController) + + /// Removes the host view controller. + func removeHostView() } -final class DuckPlayer: DuckPlayerProtocol { +/// Implementation of the DuckPlayerControlling. +final class DuckPlayer: DuckPlayerControlling { struct Constants { static let duckPlayerHost: String = "player" @@ -168,8 +219,10 @@ final class DuckPlayer: DuckPlayerProtocol { static let featureNameKey = "featureName" } + private(set) var settings: DuckPlayerSettings private(set) weak var hostView: UIViewController? + private var featureFlagger: FeatureFlagger private lazy var localeStrings: String? = { @@ -193,20 +246,37 @@ final class DuckPlayer: DuckPlayerProtocol { case overlay = "duckPlayer" } + /// Initializes a new instance of DuckPlayer with the provided settings and feature flagger. + /// + /// - Parameters: + /// - settings: The Duck Player settings. + /// - featureFlagger: The feature flag manager. init(settings: DuckPlayerSettings = DuckPlayerSettingsDefault(), featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger) { self.settings = settings self.featureFlagger = featureFlagger } - // Sets a presenting VC, so DuckPlayer can present the - // info sheet directly + /// Sets the host view controller for presenting modals. + /// + /// - Parameter vc: The view controller to set as host. public func setHostViewController(_ vc: UIViewController) { hostView = vc } + /// Removes the host view controller. + public func removeHostView() { + hostView = nil + } + // MARK: - Common Message Handlers + /// Sets user values received from the web content. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. public func setUserValues(params: Any, message: WKScriptMessage) -> Encodable? { guard let userValues: UserValues = DecodableHelper.decode(from: params) else { assertionFailure("DuckPlayer: expected JSON representation of UserValues") @@ -214,49 +284,75 @@ final class DuckPlayer: DuckPlayerProtocol { } Task { - // Fires pixels + // Fire pixels for analytics await firePixels(message: message, userValues: userValues) - // Update Settings + // Update settings based on user values await updateSettings(userValues: userValues) } return userValues } - + + /// Updates Duck Player settings based on user values. + /// + /// - Parameter userValues: The user values to update settings with. private func updateSettings(userValues: UserValues) async { settings.setMode(userValues.duckPlayerMode) settings.setAskModeOverlayHidden(userValues.askModeOverlayHidden) } + /// Retrieves user values to send to the web content. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. public func getUserValues(params: Any, message: WKScriptMessage) -> Encodable? { - // If the user is in the 'control' group, or DP is disabled sending 'nil' effectively disables - // Duckplayer in SERP, showing old overlays. - // Fixes: https://app.asana.com/0/1207252092703676/1208450923559111 - let duckPlayerExperiment = DuckPlayerLaunchExperiment() - if featureFlagger.isFeatureOn(.duckPlayer) && duckPlayerExperiment.isEnrolled && duckPlayerExperiment.isExperimentCohort { + if featureFlagger.isFeatureOn(.duckPlayer) { return encodeUserValues() } return nil - } + /// Opens a video in Duck Player within the specified web view. + /// + /// - Parameters: + /// - url: The URL of the video. + /// - webView: The web view to load the video in. @MainActor public func openVideoInDuckPlayer(url: URL, webView: WKWebView) { webView.load(URLRequest(url: url)) } + /// Performs initial setup for the player. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. @MainActor public func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> Encodable? { let webView = message.webView return await self.encodedPlayerSettings(with: webView) } + /// Performs initial setup for the overlay. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. @MainActor public func initialSetupOverlay(params: Any, message: WKScriptMessage) async -> Encodable? { let webView = message.webView return await self.encodedPlayerSettings(with: webView) } + /// Opens Duck Player settings. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. public func openDuckPlayerSettings(params: Any, message: WKScriptMessage) async -> Encodable? { NotificationCenter.default.post( name: .settingsDeepLinkNotification, @@ -266,12 +362,33 @@ final class DuckPlayer: DuckPlayerProtocol { return nil } + /// Sends a telemetry event from the FE. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. @MainActor - public func presentDuckPlayerInfo(context: DuckPlayerModalPresenter.PresentationContext) { - guard let hostView else { return } - DuckPlayerModalPresenter(context: context).presentDuckPlayerFeatureModal(on: hostView) - } + public func telemetryEvent(params: Any, message: WKScriptMessage) async -> Encodable? { + guard let event: TelemetryEvent = DecodableHelper.decode(from: params) else { + return nil + } + + switch event.attributes { + case .impression(let attrs): + switch attrs.value { + case .landscape: + Pixel.fire(pixel: .duckPlayerLandscapeLayoutImpressions) + } + } + + return nil + } + /// Opens Duck Player information modal. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. @MainActor public func openDuckPlayerInfo(params: Any, message: WKScriptMessage) async -> Encodable? { guard let body = message.body as? [String: Any], @@ -284,23 +401,18 @@ final class DuckPlayer: DuckPlayerProtocol { return nil } + /// Presents the Duck Player info modal. + /// + /// - Parameter context: The presentation context for the modal. @MainActor - public func telemetryEvent(params: Any, message: WKScriptMessage) async -> Encodable? { - guard let event: TelemetryEvent = DecodableHelper.decode(from: params) else { - return nil - } - - switch event.attributes { - case .impression(let attrs): - switch attrs.value { - case .landscape: - Pixel.fire(pixel: .duckPlayerLandscapeLayoutImpressions) - } - } - - return nil + public func presentDuckPlayerInfo(context: DuckPlayerModalPresenter.PresentationContext) { + guard let hostView else { return } + DuckPlayerModalPresenter(context: context).presentDuckPlayerFeatureModal(on: hostView) } - + + /// Encodes user values for sending to the web content. + /// + /// - Returns: An instance of `UserValues`. private func encodeUserValues() -> UserValues { return UserValues( duckPlayerMode: featureFlagger.isFeatureOn(.duckPlayer) ? settings.mode : .disabled, @@ -308,12 +420,19 @@ final class DuckPlayer: DuckPlayerProtocol { ) } + /// Encodes UI values for sending to the web content. + /// + /// - Returns: An instance of `UIValues`. private func encodeUIValues() -> UIValues { UIValues( allowFirstVideo: settings.allowFirstVideo ) } + /// Prepares and encodes player settings to send to the web content. + /// + /// - Parameter webView: The web view to check for PiP capability. + /// - Returns: An instance of `InitialPlayerSettings`. @MainActor private func encodedPlayerSettings(with webView: WKWebView?) async -> InitialPlayerSettings { let isPiPEnabled = webView?.configuration.allowsPictureInPictureMediaPlayback == true @@ -323,16 +442,22 @@ final class DuckPlayer: DuckPlayerProtocol { let playerSettings = InitialPlayerSettings.PlayerSettings(pip: pip) let userValues = encodeUserValues() let uiValues = encodeUIValues() - let settings = InitialPlayerSettings(userValues: userValues, - ui: uiValues, - settings: playerSettings, - platform: platform, - locale: locale, - localeStrings: localeStrings) + let settings = InitialPlayerSettings( + userValues: userValues, + ui: uiValues, + settings: playerSettings, + platform: platform, + locale: locale, + localeStrings: localeStrings + ) return settings } - // Accessing WKMessage needs main thread + /// Fires analytics pixels based on user interactions. + /// + /// - Parameters: + /// - message: The script message containing the interaction data. + /// - userValues: The user values to determine which pixels to fire. @MainActor private func firePixels(message: WKScriptMessage, userValues: UserValues) { @@ -341,9 +466,35 @@ final class DuckPlayer: DuckPlayerProtocol { return } guard let feature = messageData.featureName else { return } - let event: Pixel.Event = feature == FeatureName.page.rawValue ? .duckPlayerSettingAlwaysDuckPlayer : .duckPlayerSettingAlwaysDuckPlayer - if userValues.duckPlayerMode == .enabled { - Pixel.fire(pixel: event) + + // Get the webView URL + let webView = message.webView + guard let webView = message.webView, let url = webView.url else { + return + } + + // Based on the URL, determine which pixels to fire + let isSERP = url.isDuckDuckGoSearch + + // Assume we are in the SERP Overlay + if isSERP { + switch userValues.duckPlayerMode { + case .enabled: + Pixel.fire(pixel: .duckPlayerSettingsAlwaysOverlaySERP) + case .disabled: + Pixel.fire(pixel: .duckPlayerSettingsNeverOverlaySERP) + default: break + } + + // Assume we are in the Youtube Overlay + } else { + switch userValues.duckPlayerMode { + case .enabled: + Pixel.fire(pixel: .duckPlayerSettingsAlwaysOverlayYoutube) + case .disabled: + Pixel.fire(pixel: .duckPlayerSettingsNeverOverlayYoutube) + default: break + } } } diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerLaunchExperiment.swift b/DuckDuckGo/DuckPlayer/DuckPlayerLaunchExperiment.swift deleted file mode 100644 index be9821fe3e..0000000000 --- a/DuckDuckGo/DuckPlayer/DuckPlayerLaunchExperiment.swift +++ /dev/null @@ -1,239 +0,0 @@ -// -// DuckPlayerLaunchExperiment.swift -// DuckDuckGo -// -// Copyright © 2024 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import Core - - -// Date manipulation protocol to allow testing -public protocol DuckPlayerExperimentDateProvider { - var currentDate: Date { get } -} - -public class DefaultDuckPlayerExperimentDateProvider: DuckPlayerExperimentDateProvider { - public var currentDate: Date { - return Date() - } -} - -// Wrap Pixel firing in a protocol for better testing -protocol DuckPlayerExperimentPixelFiring { - static func fireDuckPlayerExperimentPixel(pixel: Pixel.Event, withAdditionalParameters params: [String: String]) -} - -extension Pixel: DuckPlayerExperimentPixelFiring { - static func fireDuckPlayerExperimentPixel(pixel: Pixel.Event, withAdditionalParameters params: [String: String]) { - self.fire(pixel: pixel, withAdditionalParameters: params, onComplete: { _ in }) - } -} - - -// Experiment Protocol -protocol DuckPlayerLaunchExperimentHandling { - var isEnrolled: Bool { get } - var isExperimentCohort: Bool { get } - var duckPlayerMode: DuckPlayerMode? { get set } - func assignUserToCohort() - func fireSearchPixels() - func fireYoutubePixel(videoID: String) -} - - -final class DuckPlayerLaunchExperiment: DuckPlayerLaunchExperimentHandling { - - private struct Constants { - static let dateFormat = "yyyyMMdd" - static let enrollmentKey = "enrollment" - static let variantKey = "variant" - static let dayKey = "day" - static let weekKey = "week" - static let stateKey = "state" - static let referrerKey = "referrer" - } - - private let referrer: DuckPlayerReferrer? - var duckPlayerMode: DuckPlayerMode? - - // Abstract Pixel firing for proper testing - private let pixel: DuckPlayerExperimentPixelFiring.Type - - // Date Provider - private let dateProvider: DuckPlayerExperimentDateProvider - - @UserDefaultsWrapper(key: .duckPlayerPixelExperimentLastWeekPixelFired, defaultValue: nil) - private var lastWeekPixelFiredV2: Int? - - @UserDefaultsWrapper(key: .duckPlayerPixelExperimentLastDayPixelFired, defaultValue: nil) - private var lastDayPixelFiredV2: Int? - - @UserDefaultsWrapper(key: .duckPlayerPixelExperimentLastVideoIDRendered, defaultValue: nil) - private var lastVideoIDReportedV2: String? - - @UserDefaultsWrapper(key: .duckPlayerPixelExperimentEnrollmentDate, defaultValue: nil) - var enrollmentDateV2: Date? - - @UserDefaultsWrapper(key: .duckPlayerPixelExperimentCohort, defaultValue: nil) - var experimentCohortV2: String? - - private var isInternalUser: Bool - - enum Cohort: String { - case control - case experiment - } - - init(duckPlayerMode: DuckPlayerMode? = nil, - referrer: DuckPlayerReferrer? = nil, - userDefaults: UserDefaults = UserDefaults.standard, - pixel: DuckPlayerExperimentPixelFiring.Type = Pixel.self, - dateProvider: DuckPlayerExperimentDateProvider = DefaultDuckPlayerExperimentDateProvider(), - isInternalUser: Bool = false) { - self.referrer = referrer - self.duckPlayerMode = duckPlayerMode - self.pixel = pixel - self.dateProvider = dateProvider - self.isInternalUser = isInternalUser - } - - private var dates: (day: Int, week: Int)? { - guard isEnrolled, - let enrollmentDate = enrollmentDateV2 else { return nil } - let currentDate = dateProvider.currentDate - let calendar = Calendar.current - let dayDifference = calendar.dateComponents([.day], from: enrollmentDate, to: currentDate).day ?? 0 - let weekDifference = (dayDifference / 7) + 1 - return (day: dayDifference, week: weekDifference) - } - - private var formattedEnrollmentDate: String? { - guard isEnrolled, - let enrollmentDate = enrollmentDateV2 else { return nil } - return Self.formattedDate(enrollmentDate) - } - - static func formattedDate(_ date: Date) -> String { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = Constants.dateFormat - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) - return dateFormatter.string(from: date) - } - - var isEnrolled: Bool { - return enrollmentDateV2 != nil && experimentCohortV2 != nil - } - - var isExperimentCohort: Bool { - return experimentCohortV2 == "experiment" - } - - func assignUserToCohort() { - if !isEnrolled { - var cohort: Cohort = Bool.random() ? .experiment : .control - - if isInternalUser { - cohort = .experiment - } - experimentCohortV2 = cohort.rawValue - enrollmentDateV2 = dateProvider.currentDate - fireEnrollmentPixel() - } - } - - private func fireEnrollmentPixel() { - guard isEnrolled, - let experimentCohortV2 = experimentCohortV2, - let formattedEnrollmentDate else { return } - - let params = [Constants.variantKey: experimentCohortV2, Constants.enrollmentKey: formattedEnrollmentDate] - pixel.fireDuckPlayerExperimentPixel(pixel: .duckplayerExperimentCohortAssign, withAdditionalParameters: params) - } - - func fireSearchPixels() { - if isEnrolled { - guard isEnrolled, - let experimentCohortV2 = experimentCohortV2, - let dates, - let formattedEnrollmentDate else { - return - } - - var params = [ - Constants.variantKey: experimentCohortV2, - Constants.dayKey: "\(dates.day)", - Constants.enrollmentKey: formattedEnrollmentDate - ] - - // Fire a base search pixel - pixel.fireDuckPlayerExperimentPixel(pixel: .duckplayerExperimentSearch, withAdditionalParameters: params) - - // Fire a daily pixel - if dates.day != lastDayPixelFiredV2 { - pixel.fireDuckPlayerExperimentPixel(pixel: .duckplayerExperimentDailySearch, withAdditionalParameters: params) - lastDayPixelFiredV2 = dates.day - } - - // Fire a weekly pixel - if dates.week != lastWeekPixelFiredV2 && dates.day > 0 { - params.removeValue(forKey: Constants.dayKey) - params[Constants.weekKey] = "\(dates.week)" - pixel.fireDuckPlayerExperimentPixel(pixel: .duckplayerExperimentWeeklySearch, withAdditionalParameters: params) - lastWeekPixelFiredV2 = dates.week - } - } - } - - func fireYoutubePixel(videoID: String) { - guard isEnrolled, - let experimentCohortV2 = experimentCohortV2, - let dates, - let formattedEnrollmentDate else { - return - } - - let params = [ - Constants.variantKey: experimentCohortV2, - Constants.dayKey: "\(dates.day)", - Constants.stateKey: duckPlayerMode?.stringValue ?? "", - Constants.referrerKey: referrer?.stringValue ?? "", - Constants.enrollmentKey: formattedEnrollmentDate - ] - if lastVideoIDReportedV2 != videoID { - pixel.fireDuckPlayerExperimentPixel(pixel: .duckplayerExperimentYoutubePageView, withAdditionalParameters: params) - lastVideoIDReportedV2 = videoID - } - } - - func cleanup() { - enrollmentDateV2 = nil - experimentCohortV2 = nil - lastDayPixelFiredV2 = nil - lastWeekPixelFiredV2 = nil - lastVideoIDReportedV2 = nil - } - - func override(control: Bool = false) { - enrollmentDateV2 = Date() - experimentCohortV2 = control ? "control" : "experiment" - lastDayPixelFiredV2 = nil - lastWeekPixelFiredV2 = nil - lastVideoIDReportedV2 = nil - - } - -} diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift index 16acb20fd1..adb9d58ab0 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift @@ -26,16 +26,51 @@ import BrowserServicesKit import DuckPlayer import os.log -final class DuckPlayerNavigationHandler { +/// Handles navigation and interactions related to Duck Player within the app. +final class DuckPlayerNavigationHandler: NSObject { + + /// The DuckPlayer instance used for handling video playback. + var duckPlayer: DuckPlayerControlling - var duckPlayer: DuckPlayerProtocol + /// Indicates where the DuckPlayer was referred from (e.g., YouTube, SERP). var referrer: DuckPlayerReferrer = .other - var lastHandledVideoID: String? + + /// Feature flag manager for enabling/disabling features. var featureFlagger: FeatureFlagger + + /// Application settings. var appSettings: AppSettings - var navigationType: WKNavigationType = .other - var experiment: DuckPlayerLaunchExperimentHandling - private lazy var internalUserDecider = AppDependencyProvider.shared.internalUserDecider + + /// Pixel firing utility for analytics. + var pixelFiring: PixelFiring.Type + let dailyPixelFiring: DailyPixelFiring.Type + + /// Keeps track of the last YouTube video watched. + var lastWatchInYoutubeVideo: String? + + // Redirection Throttle + /// Timestamp of the last Duck Player redirection. + private var lastDuckPlayerRedirect: Date? + + /// Duration to throttle Duck Player redirects. + private let lastDuckPlayerRedirectThrottleDuration: TimeInterval = 1 + + // Navigation URL Changing Throttle + /// Timestamp of the last URL change handling. + private var lastURLChangeHandling: Date? + + /// Duration to throttle URL change handling. + private let lastURLChangeHandlingThrottleDuration: TimeInterval = 1 + + // Navigation Cancelling Throttle + /// Timestamp of the last navigation handling. + private var lastNavigationHandling: Date? + + /// Duration to throttle navigation handling. + private let lastNavigationHandlingThrottleDuration: TimeInterval = 1 + + /// Delegate for handling tab navigation events. + weak var tabNavigationHandler: DuckPlayerTabNavigationHandling? private struct Constants { static let SERPURL = "duckduckgo.com/" @@ -50,21 +85,44 @@ final class DuckPlayerNavigationHandler { static let httpMethod = "GET" static let watchInYoutubePath = "openInYoutube" static let watchInYoutubeVideoParameter = "v" - static let urlInternalReferrer = "embeds_referring_euri" + static let youtubeEmbedURI = "embeds_referring_euri" static let youtubeScheme = "youtube://" static let duckPlayerScheme = URL.NavigationalScheme.duck.rawValue + static let duckPlayerReferrerParameter = "dp_referrer" + static let newTabParameter = "dp_isNewTab" + static let allowFirstVideoParameter = "dp_allowFirstVideo" + } + + private struct DuckPlayerParameters { + let referrer: DuckPlayerReferrer + let isNewTap: Bool + let allowFirstVideo: Bool } - init(duckPlayer: DuckPlayerProtocol = DuckPlayer(), + /// Initializes a new instance of `DuckPlayerNavigationHandler` with the provided dependencies. + /// + /// - Parameters: + /// - duckPlayer: The DuckPlayer instance. + /// - featureFlagger: The feature flag manager. + /// - appSettings: The application settings. + /// - pixelFiring: The pixel firing utility for analytics. + /// - dailyPixelFiring: The daily pixel firing utility for analytics. + /// - tabNavigationHandler: The tab navigation handler delegate. + init(duckPlayer: DuckPlayerControlling = DuckPlayer(), featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger, appSettings: AppSettings, - experiment: DuckPlayerLaunchExperimentHandling = DuckPlayerLaunchExperiment()) { + pixelFiring: PixelFiring.Type = Pixel.self, + dailyPixelFiring: DailyPixelFiring.Type = DailyPixel.self, + tabNavigationHandler: DuckPlayerTabNavigationHandling? = nil) { self.duckPlayer = duckPlayer self.featureFlagger = featureFlagger self.appSettings = appSettings - self.experiment = experiment + self.pixelFiring = pixelFiring + self.dailyPixelFiring = dailyPixelFiring + self.tabNavigationHandler = tabNavigationHandler } + /// Returns the file path for the Duck Player HTML template. static var htmlTemplatePath: String { guard let file = ContentScopeScripts.Bundle.path(forResource: Constants.templateName, ofType: Constants.templateExtension, @@ -75,6 +133,10 @@ final class DuckPlayerNavigationHandler { return file } + /// Creates a `URLRequest` for Duck Player using the original request's YouTube video ID and timestamp. + /// + /// - Parameter originalRequest: The original YouTube `URLRequest`. + /// - Returns: A new `URLRequest` pointing to the Duck Player. static func makeDuckPlayerRequest(from originalRequest: URLRequest) -> URLRequest { guard let (youtubeVideoID, timestamp) = originalRequest.url?.youtubeVideoParams else { assertionFailure("Request should have ID") @@ -83,6 +145,12 @@ final class DuckPlayerNavigationHandler { return makeDuckPlayerRequest(for: youtubeVideoID, timestamp: timestamp) } + /// Generates a `URLRequest` for Duck Player with a specific YouTube video ID and optional timestamp. + /// + /// - Parameters: + /// - videoID: The YouTube video ID. + /// - timestamp: Optional timestamp for the video. + /// - Returns: A `URLRequest` configured for Duck Player. static func makeDuckPlayerRequest(for videoID: String, timestamp: String?) -> URLRequest { var request = URLRequest(url: .youtubeNoCookie(videoID, timestamp: timestamp)) request.addValue(Constants.localhost, forHTTPHeaderField: Constants.refererHeader) @@ -90,6 +158,9 @@ final class DuckPlayerNavigationHandler { return request } + /// Loads and returns the HTML content from the Duck Player template file. + /// + /// - Returns: The HTML content as a `String`. static func makeHTMLFromTemplate() -> String { guard let html = try? String(contentsOfFile: htmlTemplatePath) else { assertionFailure("Should be able to load template") @@ -98,72 +169,57 @@ final class DuckPlayerNavigationHandler { return html } + /// Navigates to the Duck Player URL in the web view. Opens in a new tab if settings dictate. + /// + /// - Parameters: + /// - request: The `URLRequest` to navigate to. + /// - responseHTML: The HTML content to load. + /// - webView: The `WKWebView` to load the content into. + @MainActor private func performNavigation(_ request: URLRequest, responseHTML: String, webView: WKWebView) { - webView.loadSimulatedRequest(request, responseHTML: responseHTML) + + // If DuckPlayer is enabled, and we're watching a video in YouTube (temporarily) + // Any direct navigation to a duck:// URL should open in a new tab + if let url = webView.url, url.isYoutubeWatch && isOpenInNewTabEnabled && duckPlayerMode == .enabled { + self.redirectToDuckPlayerVideo(url: request.url, webView: webView) + return + } + // Otherwise, just load the simulated request + // New tabs require a short interval so the Omnibars dismissal propagates + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + webView.loadSimulatedRequest(request, responseHTML: responseHTML) + } } + /// Handles the Duck Player request by generating HTML from the template and performing navigation. + /// + /// - Parameters: + /// - request: The `URLRequest` to handle. + /// - webView: The `WKWebView` to load the content into. + @MainActor private func performRequest(request: URLRequest, webView: WKWebView) { let html = Self.makeHTMLFromTemplate() let duckPlayerRequest = Self.makeDuckPlayerRequest(from: request) performNavigation(duckPlayerRequest, responseHTML: html, webView: webView) } - private var duckPlayerMode: DuckPlayerMode { - let isEnabled = experiment.isEnrolled && experiment.isExperimentCohort && featureFlagger.isFeatureOn(.duckPlayer) - return isEnabled ? duckPlayer.settings.mode : .disabled + /// Checks if the Duck Player feature is enabled via feature flags. + private var isDuckPlayerFeatureEnabled: Bool { + featureFlagger.isFeatureOn(.duckPlayer) } - // Handle URL changes not triggered via Omnibar - // such as changes triggered via JS - @MainActor - private func handleURLChange(url: URL?, webView: WKWebView) { - - guard let url else { return } - - guard featureFlagger.isFeatureOn(.duckPlayer) else { - return - } - - // This is passed to the FE overlay at init to disable the overlay for one video - duckPlayer.settings.allowFirstVideo = false - - if let (videoID, _) = url.youtubeVideoParams, - videoID == lastHandledVideoID { - Logger.duckPlayer.debug("URL (\(url.absoluteString) already handled, skipping") - return - } - - // Handle Youtube internal links like "Age restricted" and "Copyright restricted" videos - // These should not be handled by DuckPlayer - if url.isYoutubeVideo, - url.hasWatchInYoutubeQueryParameter { - duckPlayer.settings.allowFirstVideo = true - return - } - - if url.isYoutubeVideo, - !url.isDuckPlayer, - let (videoID, timestamp) = url.youtubeVideoParams, - duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk { - - Logger.duckPlayer.debug("Handling URL change: \(url.absoluteString)") - webView.load(URLRequest(url: URL.duckPlayer(videoID, timestamp: timestamp))) - lastHandledVideoID = videoID - } + /// Determines if "Open in New Tab" for Duck Player is enabled in the settings. + private var isOpenInNewTabEnabled: Bool { + featureFlagger.isFeatureOn(.duckPlayer) && featureFlagger.isFeatureOn(.duckPlayerOpenInNewTab) && duckPlayer.settings.openInNewTab && duckPlayerMode != .disabled } - // Get the duck:// URL youtube-no-cookie URL - func getDuckURLFor(_ url: URL) -> URL { - guard let (youtubeVideoID, timestamp) = url.youtubeVideoParams, - url.isDuckPlayer, - !url.isDuckURLScheme, - duckPlayerMode != .disabled - else { - return url - } - return URL.duckPlayer(youtubeVideoID, timestamp: timestamp) + /// Retrieves the current mode of Duck Player based on feature flags and user settings. + private var duckPlayerMode: DuckPlayerMode { + let isEnabled = isDuckPlayerFeatureEnabled + return isEnabled ? duckPlayer.settings.mode : .disabled } + /// Checks if the YouTube app is installed on the device. private var isYouTubeAppInstalled: Bool { if let youtubeURL = URL(string: Constants.youtubeScheme) { return UIApplication.shared.canOpenURL(youtubeURL) @@ -171,31 +227,24 @@ final class DuckPlayerNavigationHandler { return false } - private func isSERPLink(navigationAction: WKNavigationAction) -> Bool { - guard let referrer = navigationAction.request.allHTTPHeaderFields?[Constants.refererHeader] else { - return false - } - if referrer.contains(Constants.SERPURL) { - return true - } - return false - } - - private func isOpenInYoutubeURL(url: URL) -> Bool { - return isWatchInYouTubeURL(url: url) - } - + /// Extracts a YouTube URL from a Duck Player "Open in YouTube" link. + /// + /// - Parameter url: The URL to parse. + /// - Returns: A YouTube `URL` if available. private func getYoutubeURLFromOpenInYoutubeLink(url: URL) -> URL? { guard isWatchInYouTubeURL(url: url), let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), let videoParameterItem = urlComponents.queryItems?.first(where: { $0.name == Constants.watchInYoutubeVideoParameter }), - let id = videoParameterItem.value, - let newURL = URL.youtube(id, timestamp: nil).addingWatchInYoutubeQueryParameter() else { + let id = videoParameterItem.value else { return nil } - return newURL + return URL.youtube(id, timestamp: nil) } + /// Determines if the URL is an "Open in YouTube" Duck Player link. + /// + /// - Parameter url: The URL to check. + /// - Returns: `true` if it's an "Open in YouTube" link, `false` otherwise. private func isWatchInYouTubeURL(url: URL) -> Bool { guard url.scheme == Constants.duckPlayerScheme, let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), @@ -205,368 +254,678 @@ final class DuckPlayerNavigationHandler { return true } - // DuckPlayer Experiment Handling - private func handleYouTubePageVisited(url: URL?, navigationAction: WKNavigationAction?) { - guard let url else { return } + /// Redirects the web view to play the video in Duck Player, optionally forcing a new tab. + /// + /// - Parameters: + /// - url: The URL of the video. + /// - webView: The `WKWebView` to load the content into. + /// - forceNewTab: Whether to force opening in a new tab. + /// - disableNewTab: Ignore openInNewTab settings + @MainActor + private func redirectToDuckPlayerVideo(url: URL?, webView: WKWebView, forceNewTab: Bool = false, disableNewTab: Bool = false) { - // Parse openInYoutubeURL if present - let newURL = getYoutubeURLFromOpenInYoutubeLink(url: url) ?? url + guard let url, + let (videoID, _) = url.youtubeVideoParams else { return } - guard let (videoID, _) = newURL.youtubeVideoParams else { return } + let duckPlayerURL = URL.duckPlayer(videoID) + self.loadWithDuckPlayerParameters(URLRequest(url: duckPlayerURL), referrer: self.referrer, webView: webView, forceNewTab: forceNewTab, disableNewTab: disableNewTab) + } + + /// Redirects to the YouTube video page, allowing the first video if necessary. + /// + /// - Parameters: + /// - url: The URL of the video. + /// - webView: The `WKWebView` to load the content into. + /// - forceNewTab: Whether to force opening in a new tab. + /// - allowFirstVideo: Hide DuckPlayer Overlay in the first loaded video + /// - disableNewTab: Ignore openInNewTab settings + @MainActor + private func redirectToYouTubeVideo(url: URL?, webView: WKWebView, forceNewTab: Bool = false, allowFirstVideo: Bool = true, disableNewTab: Bool = false) { - // If this is a SERP link, set the referrer accordingly - if let navigationAction, isSERPLink(navigationAction: navigationAction) { - referrer = .serp + guard let url else { return } + + var redirectURL = url + + // Parse OpenInYouTubeURLs if present + if let parsedURL = getYoutubeURLFromOpenInYoutubeLink(url: url) { + redirectURL = parsedURL } + + // When redirecting to YouTube, we always allow the first video + loadWithDuckPlayerParameters(URLRequest(url: redirectURL), referrer: referrer, webView: webView, forceNewTab: forceNewTab, allowFirstVideo: allowFirstVideo, disableNewTab: disableNewTab) + } + + + /// Fires analytics pixels when Duck Player is viewed, based on referrer and settings. + private func fireDuckPlayerPixels(webView: WKWebView) { - if featureFlagger.isFeatureOn(.duckPlayer) || internalUserDecider.isInternalUser { - - // DuckPlayer Experiment run - let experiment = DuckPlayerLaunchExperiment(duckPlayerMode: duckPlayerMode, - referrer: referrer, - isInternalUser: internalUserDecider.isInternalUser) - - // Enroll user if not enrolled - if !experiment.isEnrolled { - experiment.assignUserToCohort() - - // DuckPlayer is disabled before user enrolls, - // So trigger a settings change notification - // to let the FE know about the 'actual' setting - // and update Experiment value - if experiment.isExperimentCohort { - duckPlayer.settings.triggerNotification() - experiment.duckPlayerMode = duckPlayer.settings.mode + // First daily unique user Duck Player view + dailyPixelFiring.fireDaily(.duckPlayerDailyUniqueView, withAdditionalParameters: ["settings": duckPlayerMode.stringValue]) + + // Duck Player viewed with Always setting, referred from YouTube (automatic) + if (referrer == .youtube) && duckPlayerMode == .enabled { + pixelFiring.fire(.duckPlayerViewFromYoutubeAutomatic, withAdditionalParameters: [:]) + } + + // Duck Player viewed from SERP + if referrer == .serp { + pixelFiring.fire(.duckPlayerViewFromSERP, withAdditionalParameters: [:]) + } + + // Other referrers + if referrer == .other || referrer == .undefined { + pixelFiring.fire(.duckPlayerViewFromOther, withAdditionalParameters: [:]) + } + + } + + /// Fires an analytics pixel when the user opts to watch a video on YouTube instead. + private func fireOpenInYoutubePixel() { + pixelFiring.fire(.duckPlayerWatchOnYoutube, withAdditionalParameters: [:]) + } + + /// Cancels JavaScript-triggered navigation by stopping the load and going back if possible. + /// + /// - Parameters: + /// - webView: The `WKWebView` to manipulate. + /// - completion: Optional completion handler. + @MainActor + private func cancelJavascriptNavigation(webView: WKWebView, completion: (() -> Void)? = nil) { + + if duckPlayerMode == .enabled { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + webView.stopLoading() + if webView.canGoBack { + webView.goBack() } + completion?() + } + } else { + completion?() + } + + } + + /// Loads a request with Duck Player parameters, handling new tab logic and first video allowance. + /// + /// - Parameters: + /// - request: The `URLRequest` to load. + /// - referrer: The referrer information. + /// - webView: The `WKWebView` to load the content into. + /// - forceNewTab: Whether to force opening in hana new tab. + /// - allowFirstVideo: Whether to allow the first video to play. + /// - disableNewTab: Ignores Open in New tab settings + private func loadWithDuckPlayerParameters(_ request: URLRequest, + referrer: DuckPlayerReferrer, + webView: WKWebView, + forceNewTab: Bool = false, + allowFirstVideo: Bool = false, + disableNewTab: Bool = false) { + + guard let url = request.url else { + return + } + + // We want to prevent multiple simultaneous redirects + // This can be caused by Duplicate Nav events, and YouTube's own redirects + if let lastTimestamp = lastDuckPlayerRedirect { + let timeSinceLastThrottle = Date().timeIntervalSince(lastTimestamp) + if timeSinceLastThrottle < lastDuckPlayerRedirectThrottleDuration { + return } + } + lastDuckPlayerRedirect = Date() + + // Remove any DP Parameters + guard let strippedURL = removeDuckPlayerParameters(from: url) else { + return + } + + // Set allowFirstVideo + duckPlayer.settings.allowFirstVideo = allowFirstVideo + + // Get parameter values + let isNewTab = (isOpenInNewTabEnabled && duckPlayerMode == .enabled) || forceNewTab ? "1" : "0" + let allowFirstVideo = allowFirstVideo ? "1" : "0" + let referrer = referrer.rawValue + + var newURL = strippedURL + var urlComponents = URLComponents(url: strippedURL, resolvingAgainstBaseURL: false) + var queryItems = urlComponents?.queryItems ?? [] - experiment.fireYoutubePixel(videoID: videoID) + // Append DuckPlayer parameters + queryItems.append(URLQueryItem(name: Constants.newTabParameter, value: isNewTab)) + queryItems.append(URLQueryItem(name: Constants.duckPlayerReferrerParameter, value: referrer)) + queryItems.append(URLQueryItem(name: Constants.allowFirstVideoParameter, value: allowFirstVideo)) + urlComponents?.queryItems = queryItems + + // Create a new request with the modified URL + newURL = urlComponents?.url ?? newURL + + // Only Open in new tab if enabled + if (isOpenInNewTabEnabled || forceNewTab) && !disableNewTab { + tabNavigationHandler?.openTab(for: newURL) + } else { + webView.load(URLRequest(url: newURL)) } - + + Logger.duckPlayer.debug("DP: loadWithDuckPlayerParameters: \(newURL.absoluteString)") + + } + + /// Extracts Duck Player-specific parameters from the URL for internal use. + /// + /// - Parameter url: The URL to parse. + /// - Returns: A `DuckPlayerParameters` struct containing the extracted values. + private func getDuckPlayerParameters(url: URL) -> DuckPlayerParameters { + + guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = urlComponents.queryItems else { + return DuckPlayerParameters(referrer: .other, isNewTap: false, allowFirstVideo: false) + } + + let referrerValue = queryItems.first(where: { $0.name == Constants.duckPlayerReferrerParameter })?.value + let allowFirstVideoValue = queryItems.first(where: { $0.name == Constants.allowFirstVideoParameter })?.value + let isNewTabValue = queryItems.first(where: { $0.name == Constants.newTabParameter })?.value + let youtubeEmbedURI = queryItems.first(where: { $0.name == Constants.youtubeEmbedURI })?.value + + // Use the from(string:) method to parse referrer + let referrer = DuckPlayerReferrer(string: referrerValue ?? "") + let allowFirstVideo = allowFirstVideoValue == "1" || youtubeEmbedURI.map(\.isEmpty) ?? false + let isNewTab = isNewTabValue == "1" + + return DuckPlayerParameters(referrer: referrer, isNewTap: isNewTab, allowFirstVideo: allowFirstVideo) + } + + /// Removes Duck Player-specific query parameters from a URL. + /// + /// - Parameter url: The URL to clean. + /// - Returns: A new URL without Duck Player parameters. + private func removeDuckPlayerParameters(from url: URL) -> URL? { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return url + } + + let parametersToRemove = [Constants.newTabParameter, + Constants.duckPlayerReferrerParameter] + + // Filter out the parameters you want to remove + components.queryItems = queryItems.filter { !parametersToRemove.contains($0.name) } + + // Return the modified URL + return components.url + } + + /// Determines if a URL is a DuckPlayer redirect based on its parameters + /// + /// - Parameter url: To check + /// - Returns: True | False + private func isDuckPlayerRedirect(url: URL) -> Bool { + + guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = urlComponents.queryItems else { + return false + } + + let referrerValue = queryItems.first(where: { $0.name == Constants.duckPlayerReferrerParameter })?.value + let allowFirstVideoValue = queryItems.first(where: { $0.name == Constants.allowFirstVideoParameter })?.value + let isNewTabValue = queryItems.first(where: { $0.name == Constants.newTabParameter })?.value + let youtubeEmbedURI = queryItems.first(where: { $0.name == Constants.youtubeEmbedURI })?.value + + return referrerValue != nil || allowFirstVideoValue != nil || isNewTabValue != nil || youtubeEmbedURI != nil + } + + /// Sets the referrer based on the current web view URL to aid in analytics. + /// + /// - Parameter webView: The `WKWebView` whose URL is used to determine the referrer. + private func setReferrer(webView: WKWebView) { + + // Make sure we are NOT DuckPlayer + guard let url = webView.url, !url.isDuckPlayer else { return } + + // First, try to use the back Item + var backItems = webView.backForwardList.backList.reversed() + + // Ignore any previous URL that's duckPlayer or youtube-no-cookie + if backItems.first?.url != nil, url.isDuckPlayer { + backItems = webView.backForwardList.backList.dropLast().reversed() + } + + // If the current URL is DuckPlayer, use the previous history item + guard let referrerURL = url.isDuckPlayer ? backItems.first?.url : url else { + return + } + + // SERP as a referrer + if referrerURL.isDuckDuckGoSearch { + referrer = .serp + return + } + + // Set to Youtube for "Watch in Youtube videos" + if referrerURL.isYoutubeWatch && duckPlayerMode == .enabled && duckPlayer.settings.allowFirstVideo { + referrer = .youtube + return + } + + // Set to Overlay for Always ask + if referrerURL.isYoutubeWatch && duckPlayerMode == .alwaysAsk { + referrer = .youtubeOverlay + return + } + + // Any Other Youtube URL or other referrer + if referrerURL.isYoutube { + referrer = .youtube + return + } else { + referrer = .other + } + } - // Determines if the link should be opened in a new tab - // And sets the correct navigationType - // This is uses for JS based navigation links - private func setOpenInNewTab(url: URL?) { - guard let url else { + /// Determines if the current tab is a new tab based on the targetFrame request and other params + /// + /// - Parameter navigationAction: The `WKNavigationAction` used to determine the tab type. + private func isNewTab(_ navigationAction: WKNavigationAction) -> Bool { + + guard let request = navigationAction.targetFrame?.safeRequest, + let url = request.url else { + return false + } + + // Always return false if open in new tab is disabled + guard isOpenInNewTabEnabled else { return false } + + // If the target frame is duckPlayer itself or there's no URL + // we're at a new tab + if url.isDuckPlayer || url.isEmpty { + return true + } + + return false + } + + /// // Handle "open in YouTube" links (duck://player/openInYoutube) + /// + /// - Parameter url: The `URL` used to determine the tab type. + /// - Parameter webView: The `WebView` used for navigation/redirection + @MainActor + private func handleOpenInYoutubeLink(url: URL, webView: WKWebView) { + + // Handle "open in YouTube" links (duck://player/openInYoutube) + guard let (videoID, _) = url.youtubeVideoParams else { return } - // let openInNewTab = appSettings.duckPlayerOpenInNewTab - let openInNewTab = appSettings.duckPlayerOpenInNewTab - let isFeatureEnabled = featureFlagger.isFeatureOn(.duckPlayer) - let isSubFeatureEnabled = featureFlagger.isFeatureOn(.duckPlayerOpenInNewTab) || internalUserDecider.isInternalUser - let isDuckPlayerEnabled = duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk + // Fire a Pixel for Open in YouTube + self.fireOpenInYoutubePixel() - if openInNewTab && - isFeatureEnabled && - isSubFeatureEnabled && - isDuckPlayerEnabled { - navigationType = .linkActivated + // Attempt to open in YouTube app or load in webView + if appSettings.allowUniversalLinks, isYouTubeAppInstalled, + let youtubeAppURL = URL(string: "\(Constants.youtubeScheme)\(videoID)") { + UIApplication.shared.open(youtubeAppURL) } else { - navigationType = .other + // Watch in YT videos always open in new tab + redirectToYouTubeVideo(url: url, webView: webView, forceNewTab: true) } + + } } extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling { - - // Handle rendering the simulated request if the URL is duck:// - // and DuckPlayer is either enabled or alwaysAsk + + /// Manages navigation actions to Duck Player URLs, handling redirects and loading as needed. + /// + /// - Parameters: + /// - navigationAction: The `WKNavigationAction` to handle. + /// - webView: The `WKWebView` where navigation is occurring. @MainActor - func handleNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) { + func handleDuckNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) { - Logger.duckPlayer.debug("Handling DuckPlayer Player Navigation for \(navigationAction.request.url?.absoluteString ?? "")") - - // This is passed to the FE overlay at init to disable the overlay for one video - duckPlayer.settings.allowFirstVideo = false - - guard let url = navigationAction.request.url else { return } - - guard featureFlagger.isFeatureOn(.duckPlayer) else { return } - - // Handle Youtube internal links like "Age restricted" and "Copyright restricted" videos - // These should not be handled by DuckPlayer - if url.isYoutubeVideo, - url.hasWatchInYoutubeQueryParameter { - return + // We want to prevent multiple simultaneous redirects + // This can be caused by Duplicate Nav events, and quick URL changes + if let lastTimestamp = lastNavigationHandling, + Date().timeIntervalSince(lastTimestamp) < lastNavigationHandlingThrottleDuration { + return } - // Handle Open in Youtube Links - // duck://player/openInYoutube?v=12345 - if let newURL = getYoutubeURLFromOpenInYoutubeLink(url: url) { - - Pixel.fire(pixel: Pixel.Event.duckPlayerWatchOnYoutube) - - // These links should always skip the overlay - duckPlayer.settings.allowFirstVideo = true + lastNavigationHandling = Date() - // Attempt to open in YouTube app (if installed) or load in webView - if appSettings.allowUniversalLinks, - isYouTubeAppInstalled, - let (videoID, _) = newURL.youtubeVideoParams, - let url = URL(string: "\(Constants.youtubeScheme)\(videoID)") { - UIApplication.shared.open(url) - } else { - webView.load(URLRequest(url: newURL)) + guard let url = navigationAction.request.url else { return } + + // Redirect to YouTube if DuckPlayer is disabled + guard duckPlayerMode != .disabled else { + if let (videoID, _) = url.youtubeVideoParams { + redirectToYouTubeVideo(url: URL.youtube(videoID), webView: webView) } return } - - // Daily Unique View Pixel - if url.isDuckPlayer, - duckPlayerMode != .disabled { - let setting = duckPlayerMode == .enabled ? Constants.duckPlayerAlwaysString : Constants.duckPlayerDefaultString - DailyPixel.fire(pixel: Pixel.Event.duckPlayerDailyUniqueView, withAdditionalParameters: [Constants.settingsKey: setting]) + // Handle "open in YouTube" links (duck://player/openInYoutube) + if let openInYouTubeURL = getYoutubeURLFromOpenInYoutubeLink(url: url) { + handleOpenInYoutubeLink(url: openInYouTubeURL, webView: webView) + return } - // Pixel for Views From Youtube - if referrer == .youtube, - duckPlayerMode == .enabled { - Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromYoutubeAutomatic) - } - + // Determine navigation type + let shouldOpenInNewTab = isOpenInNewTabEnabled && !isNewTab(navigationAction) + + // Handle duck:// scheme URLs (Or direct navigation to duck player) if url.isDuckURLScheme { - - // If DuckPlayer is Enabled or in ask mode, render the video - if duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk, + + // If should be opened in a new tab, and it's not a DuckPlayer URL, it means this + // is a direct duck:// navigation, so we need to properly redirect to a duckPlayer version + if shouldOpenInNewTab && !isDuckPlayerRedirect(url: url) { + redirectToDuckPlayerVideo(url: url, webView: webView, forceNewTab: true) + return + } + + // Simulate DuckPlayer request if in enabled/ask mode and not redirected to YouTube + if duckPlayerMode != .disabled, !url.hasWatchInYoutubeQueryParameter { let newRequest = Self.makeDuckPlayerRequest(from: URLRequest(url: url)) - Logger.duckPlayer.debug("DP: Loading Simulated Request for \(navigationAction.request.url?.absoluteString ?? "")") - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // The webView needs some time for state to propagate + // Before performing the simulated request + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { self.performRequest(request: newRequest, webView: webView) + self.fireDuckPlayerPixels(webView: webView) } - - // Otherwise, just redirect to YouTube } else { - if let (videoID, timestamp) = url.youtubeVideoParams { - let youtubeURL = URL.youtube(videoID, timestamp: timestamp) - let request = URLRequest(url: youtubeURL) - webView.load(request) - } + redirectToYouTubeVideo(url: url, webView: webView) } return } - + + // Handle YouTube watch URLs based on DuckPlayer settings + if url.isYoutubeWatch, duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk { + if url.hasWatchInYoutubeQueryParameter { + redirectToYouTubeVideo(url: url, webView: webView) + } else { + redirectToDuckPlayerVideo(url: url, webView: webView, forceNewTab: shouldOpenInNewTab) + } + } } - // DecidePolicyFor handler to redirect relevant requests - // to duck://player + /// Observes URL changes and redirects to Duck Player when appropriate, avoiding duplicate handling. + /// + /// - Parameter webView: The `WKWebView` whose URL has changed. + /// - Returns: A result indicating whether the URL change was handled. @MainActor - func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, - completion: @escaping (WKNavigationActionPolicy) -> Void, - webView: WKWebView) { + func handleURLChange(webView: WKWebView) -> DuckPlayerNavigationHandlerURLChangeResult { - Logger.duckPlayer.debug("Handling DecidePolicyFor for \(navigationAction.request.url?.absoluteString ?? "")") - - // This means navigation originated in user Event - // and not automatic. This is used further to - // determine how navigation is performed (new tab, etc) - // Resets on next attachment - if navigationAction.navigationType == .linkActivated { - self.navigationType = navigationAction.navigationType + // We want to prevent multiple simultaneous redirects + // This can be caused by Duplicate Nav events, and quick URL changes + if let lastTimestamp = lastURLChangeHandling, + Date().timeIntervalSince(lastTimestamp) < lastURLChangeHandlingThrottleDuration { + return .notHandled(.duplicateNavigation) } - guard let url = navigationAction.request.url else { - completion(.cancel) - return - } + // Update the Referrer based on the first URL change detected + setReferrer(webView: webView) - guard featureFlagger.isFeatureOn(.duckPlayer) else { - completion(.allow) - return + // We don't want YouTube redirects happening while default navigation is happening + // This can be caused by Duplicate Nav events, and quick URL changes + if let lastTimestamp = lastNavigationHandling, + Date().timeIntervalSince(lastTimestamp) < lastNavigationHandlingThrottleDuration { + return .notHandled(.duplicateNavigation) } - // This is passed to the FE overlay at init to disable the overlay for one video - duckPlayer.settings.allowFirstVideo = false - - if let (videoID, _) = url.youtubeVideoParams, - videoID == lastHandledVideoID, - !url.hasWatchInYoutubeQueryParameter { - Logger.duckPlayer.debug("DP: DecidePolicy: URL (\(url.absoluteString)) already handled, skipping") - completion(.cancel) - return + // Check if DuckPlayer feature is enabled + guard isDuckPlayerFeatureEnabled else { + return .notHandled(.featureOff) } - // Handle Youtube internal links like "Age restricted" and "Copyright restricted" videos - // These should not be handled by DuckPlayer and not include overlays - if url.isYoutubeVideo, - url.hasWatchInYoutubeQueryParameter { - duckPlayer.settings.allowFirstVideo = true - completion(.allow) - return - } - - // SERP referals - if isSERPLink(navigationAction: navigationAction) { - // Set the referer - referrer = .serp - - if duckPlayerMode == .enabled, !url.isDuckPlayer { - Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromSERP, debounce: 2) - } - - } else { - Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromOther, debounce: 2) + guard let url = webView.url, let (videoID, _) = url.youtubeVideoParams else { + return .notHandled(.invalidURL) } - - if url.isYoutubeVideo, - !url.isDuckPlayer, - duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk { - Logger.duckPlayer.debug("DP: Handling decidePolicy for Duck Player with \(url.absoluteString)") - completion(.cancel) - handleURLChange(url: url, webView: webView) - return + guard url.isYoutubeWatch else { + return .notHandled(.isNotYoutubeWatch) } - completion(.allow) - } - - @MainActor - func handleJSNavigation(url: URL?, webView: WKWebView) { + guard videoID != lastWatchInYoutubeVideo else { + lastURLChangeHandling = Date() + return .handled + } - Logger.duckPlayer.debug("Handling JS Navigation for \(url?.absoluteString ?? "")") + let parameters = getDuckPlayerParameters(url: url) - guard featureFlagger.isFeatureOn(.duckPlayer) else { - return + // If the URL has the allow first video, we just don't handle it + if parameters.allowFirstVideo { + lastWatchInYoutubeVideo = videoID + lastURLChangeHandling = Date() + return .handled } - // Assume JS Navigation is user-triggered - self.navigationType = .linkActivated - - // Only handle URL changes if the allowFirstVideo is set to false - // This prevents Youtube redirects from triggering DuckPlayer when is not expected - if !duckPlayer.settings.allowFirstVideo { - handleURLChange(url: url, webView: webView) + guard duckPlayerMode == .enabled else { + return .notHandled(.duckPlayerDisabled) } + + // Handle YouTube watch URLs based on DuckPlayer settings + if duckPlayerMode == .enabled && !parameters.allowFirstVideo { + cancelJavascriptNavigation(webView: webView, completion: { + self.redirectToDuckPlayerVideo(url: url, webView: webView) + }) + lastURLChangeHandling = Date() + Logger.duckPlayer.debug("Handling URL change for \(webView.url?.absoluteString ?? "")") + return .handled + } else { + + } + + return .notHandled(.isNotYoutubeWatch) } + /// Custom back navigation logic to handle Duck Player in the web view's history stack. + /// + /// - Parameter webView: The `WKWebView` to navigate back in. @MainActor func handleGoBack(webView: WKWebView) { - - Logger.duckPlayer.debug("DP: Handling Back Navigation") - - let experiment = DuckPlayerLaunchExperiment() - let duckPlayerMode = experiment.isExperimentCohort ? duckPlayerMode : .disabled - - guard featureFlagger.isFeatureOn(.duckPlayer) else { + + guard isDuckPlayerFeatureEnabled else { webView.goBack() return } - lastHandledVideoID = nil - webView.stopLoading() - - // Check if the back list has items + // Check if the back list has items, and if not try to close the tab guard !webView.backForwardList.backList.isEmpty else { - webView.goBack() + tabNavigationHandler?.closeTab() return } - + // Find the last non-YouTube video URL in the back list - // and navigate to it let backList = webView.backForwardList.backList var nonYoutubeItem: WKBackForwardListItem? - + for item in backList.reversed() where !item.url.isYoutubeVideo && !item.url.isDuckPlayer { nonYoutubeItem = item break } - + if let nonYoutubeItem = nonYoutubeItem, duckPlayerMode == .enabled { - Logger.duckPlayer.debug("DP: Navigating back to \(nonYoutubeItem.url.absoluteString)") + // Delay stopping the loading to avoid interference with go(to:) + webView.stopLoading() webView.go(to: nonYoutubeItem) } else { - Logger.duckPlayer.debug("DP: Navigating back to previous page") + webView.stopLoading() webView.goBack() } } + - // Handle Reload for DuckPlayer Videos + /// Handles reload actions, ensuring Duck Player settings are respected during the reload. + /// + /// - Parameter webView: The `WKWebView` to reload. @MainActor func handleReload(webView: WKWebView) { - Logger.duckPlayer.debug("DP: Handling Reload") - - guard featureFlagger.isFeatureOn(.duckPlayer) else { + // Reset DuckPlayer status + duckPlayer.settings.allowFirstVideo = false + + guard isDuckPlayerFeatureEnabled else { webView.reload() return } - lastHandledVideoID = nil - webView.stopLoading() - if let url = webView.url, url.isDuckPlayer, - !url.isDuckURLScheme, - let (videoID, timestamp) = url.youtubeVideoParams, - duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk { - Logger.duckPlayer.debug("DP: Handling DuckPlayer Reload for \(url.absoluteString)") - webView.load(URLRequest(url: .duckPlayer(videoID, timestamp: timestamp))) - } else { - webView.reload() + guard let url = webView.url else { + return + } + + if url.isDuckPlayer, duckPlayerMode != .disabled { + redirectToDuckPlayerVideo(url: url, webView: webView, disableNewTab: true) + return + } + + if url.isYoutubeWatch, duckPlayerMode == .alwaysAsk { + redirectToYouTubeVideo(url: url, webView: webView, allowFirstVideo: false, disableNewTab: true) + return } + + webView.reload() + } + /// Initializes settings and potentially redirects when the handler is attached to a web view. + /// + /// - Parameter webView: The `WKWebView` being attached. @MainActor func handleAttach(webView: WKWebView) { - Logger.duckPlayer.debug("DP: Attach WebView") + // Reset referrer and initial settings + referrer = .other - guard featureFlagger.isFeatureOn(.duckPlayer) else { + // Ensure feature and mode are enabled + guard isDuckPlayerFeatureEnabled, + let url = webView.url, + duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk else { return } - if let url = webView.url, url.isDuckPlayer, - !url.isDuckURLScheme, - duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk { - Logger.duckPlayer.debug("DP: Handling Initial Load of a video for \(url.absoluteString)") - handleReload(webView: webView) + // Get parameters and determine redirection + let parameters = getDuckPlayerParameters(url: url) + if parameters.allowFirstVideo { + redirectToYouTubeVideo(url: url, webView: webView) + } else { + referrer = parameters.referrer + redirectToDuckPlayerVideo(url: url, webView: webView, disableNewTab: true) } + } + + /// Updates the referrer after the web view finishes loading a page. + /// + /// - Parameter webView: The `WKWebView` that finished loading. + @MainActor + func handleDidFinishLoading(webView: WKWebView) { + + // Reset allowFirstVideo + duckPlayer.settings.allowFirstVideo = false } - // Handle custom events - // This method is used to delegate tasks to DuckPlayerHandler, such as firing pixels and etc. - func handleEvent(event: DuckPlayerNavigationEvent, url: URL?, navigationAction: WKNavigationAction?) { - switch event { - case .youtubeVideoPageVisited: - handleYouTubePageVisited(url: url, navigationAction: navigationAction) - case .JSTriggeredNavigation: - setOpenInNewTab(url: url) + /// Resets settings when the web view starts loading a new page. + /// + /// - Parameter webView: The `WKWebView` that started loading. + @MainActor + func handleDidStartLoading(webView: WKWebView) { + + setReferrer(webView: webView) + + // Automatically reset allowFirstVideo after loading starts + // This is a fallback as the WKNavigation Delegate does not + // Always fires finishLoading (For JS Navigation) which + // triggers handleDidFinishLoading + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.duckPlayer.settings.allowFirstVideo = false } + } - // Determine if the links should be open in a new tab, based on the navigationAction and User setting - // This is used for manually activated links - func shouldOpenInNewTab(_ navigationAction: WKNavigationAction, webView: WKWebView) -> Bool { + /// Converts a standard YouTube URL to its Duck Player equivalent if applicable. + /// + /// - Parameter url: The YouTube `URL` to convert. + /// - Returns: A Duck Player `URL` if applicable. + func getDuckURLFor(_ url: URL) -> URL { + guard let (youtubeVideoID, timestamp) = url.youtubeVideoParams, + url.isDuckPlayer, + !url.isDuckURLScheme, + duckPlayerMode != .disabled + else { + return url + } + return URL.duckPlayer(youtubeVideoID, timestamp: timestamp) + } + + /// Decides whether to cancel navigation to prevent opening the YouTube app from the web view. + /// + /// - Parameters: + /// - navigationAction: The `WKNavigationAction` to evaluate. + /// - webView: The `WKWebView` where navigation is occurring. + /// - Returns: `true` if the navigation should be canceled, `false` otherwise. + @MainActor + func handleDelegateNavigation(navigationAction: WKNavigationAction, webView: WKWebView) -> Bool { - // let openInNewTab = appSettings.duckPlayerOpenInNewTab - let openInNewTab = appSettings.duckPlayerOpenInNewTab - let isFeatureEnabled = featureFlagger.isFeatureOn(.duckPlayer) - let isSubFeatureEnabled = featureFlagger.isFeatureOn(.duckPlayerOpenInNewTab) || internalUserDecider.isInternalUser - let isDuckPlayer = navigationAction.request.url?.isDuckPlayer ?? false - let isDuckPlayerEnabled = duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk + guard let url = navigationAction.request.url else { + return false + } - if openInNewTab && - isFeatureEnabled && - isSubFeatureEnabled && - isDuckPlayer && - self.navigationType == .linkActivated && - isDuckPlayerEnabled { + // Only account for MainFrame navigation + guard navigationAction.isTargetingMainFrame() else { + return false + } + + // Only if DuckPlayer is enabled + guard isDuckPlayerFeatureEnabled else { + return false + } + + // Only account for in 'Always' mode + if duckPlayerMode == .disabled { + return false + } + + // Only account for in 'Duck Player' URL + if url.isDuckPlayer { + return false + } + + // Do not intercept any back/forward navigation + if navigationAction.navigationType == .backForward { + return false + } + + // Ignore YouTube Watch URLs if allowFirst video is set + if url.isYoutubeWatch && duckPlayer.settings.allowFirstVideo { + return false + } + + // Redirect to Duck Player if enabled + if url.isYoutubeWatch && duckPlayerMode == .enabled && !isDuckPlayerRedirect(url: url) { + redirectToDuckPlayerVideo(url: url, webView: webView) return true } + + // Redirect to Youtube + DuckPlayer Overlay if Ask Mode + if url.isYoutubeWatch && duckPlayerMode == .alwaysAsk && !isDuckPlayerRedirect(url: url) { + redirectToYouTubeVideo(url: url, webView: webView, allowFirstVideo: false) + return true + } + + // Allow everything else return false + } } extension WKWebView { - var isEmptyTab: Bool { - return self.url == nil || self.url?.absoluteString == "about:blank" + /// Returns the count of items in the web view's back navigation list. + @objc func backListItemsCount() -> Int { + return backForwardList.backList.count } + } diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift index 2c9ce16bd0..1755a54cf2 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift @@ -19,26 +19,136 @@ import WebKit -enum DuckPlayerNavigationEvent { - case youtubeVideoPageVisited - case JSTriggeredNavigation +/// Represents the referrer source for the Duck Player. +public enum DuckPlayerReferrer: String { + + case youtube + case youtubeOverlay + case serp + case other + case undefined +} + +extension DuckPlayerReferrer { + /// Initializes a `DuckPlayerReferrer` from a string value. + /// + /// - Parameter string: The string representation of the referrer. + init(string: String) { + self = DuckPlayerReferrer(rawValue: string) ?? .undefined + } +} + +/// Represents the result of handling a URL change in the Duck Player navigation handler. +enum DuckPlayerNavigationHandlerURLChangeResult { + + /// Possible reasons for not handling a URL change. + enum HandlingResult { + case featureOff + case invalidURL + case duckPlayerDisabled + case isNotYoutubeWatch + case disabledForVideo + case duplicateNavigation + } + + case handled + case notHandled(HandlingResult) } +/// Represents the direction of navigation in the Duck Player. +enum DuckPlayerNavigationDirection { + case back + case forward +} + +@MainActor +/// Protocol defining the navigation handling for Duck Player. protocol DuckPlayerNavigationHandling: AnyObject { + + /// The referrer of the Duck Player. var referrer: DuckPlayerReferrer { get set } - var duckPlayer: DuckPlayerProtocol { get } - func handleNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) - func handleJSNavigation(url: URL?, webView: WKWebView) - func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, - completion: @escaping (WKNavigationActionPolicy) -> Void, - webView: WKWebView) + + /// Delegate for handling tab navigation events. + var tabNavigationHandler: DuckPlayerTabNavigationHandling? { get set } + + /// The DuckPlayer instance used for handling video playback. + var duckPlayer: DuckPlayerControlling { get } + + /// Handles URL changes in the web view. + /// + /// - Parameter webView: The web view where the URL change occurred. + /// - Returns: The result of handling the URL change. + func handleURLChange(webView: WKWebView) -> DuckPlayerNavigationHandlerURLChangeResult + + /// Handles the back navigation action in the web view. + /// + /// - Parameter webView: The web view to navigate back in. func handleGoBack(webView: WKWebView) + + /// Handles the reload action in the web view. + /// + /// - Parameter webView: The web view to reload. func handleReload(webView: WKWebView) + + /// Performs actions when the handler is attached to a web view. + /// + /// - Parameter webView: The web view being attached. func handleAttach(webView: WKWebView) + + /// Handles the start of page loading in the web view. + /// + /// - Parameter webView: The web view that started loading. + func handleDidStartLoading(webView: WKWebView) + + /// Handles the completion of page loading in the web view. + /// + /// - Parameter webView: The web view that finished loading. + func handleDidFinishLoading(webView: WKWebView) + + /// Converts a standard YouTube URL to its Duck Player equivalent if applicable. + /// + /// - Parameter url: The YouTube URL to convert. + /// - Returns: A Duck Player URL if applicable. func getDuckURLFor(_ url: URL) -> URL - func handleEvent(event: DuckPlayerNavigationEvent, - url: URL?, - navigationAction: WKNavigationAction?) - func shouldOpenInNewTab(_ navigationAction: WKNavigationAction, webView: WKWebView) -> Bool + /// Handles navigation actions to Duck Player URLs. + /// + /// - Parameters: + /// - navigationAction: The navigation action to handle. + /// - webView: The web view where navigation is occurring. + func handleDuckNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) + + /// Decides whether to cancel navigation to prevent opening the YouTube app from the web view. + /// + /// - Parameters: + /// - navigationAction: The navigation action to evaluate. + /// - webView: The web view where navigation is occurring. + /// - Returns: `true` if the navigation should be canceled, `false` otherwise. + func handleDelegateNavigation(navigationAction: WKNavigationAction, webView: WKWebView) -> Bool +} + +/// Protocol defining the tab navigation handling for Duck Player. +protocol DuckPlayerTabNavigationHandling: AnyObject { + /// Opens a new tab for the specified URL. + /// + /// - Parameter url: The URL to open in a new tab. + func openTab(for url: URL) + + /// Closes the current tab. + func closeTab() +} + +/// Protocol defining a navigation action for Duck Player. +protocol NavigationActionProtocol { + + var request: URLRequest { get } + var isTargetingMainFrame: Bool { get } + var navigationType: WKNavigationType { get } +} + +extension WKNavigationAction: NavigationActionProtocol { + /// Indicates whether the navigation action targets the main frame. + var isTargetingMainFrame: Bool { + return self.targetFrame?.isMainFrame ?? false + } } diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift b/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift index c650284fcc..2100b563bd 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift @@ -21,13 +21,14 @@ import BrowserServicesKit import Combine import Core +/// Represents the different modes for Duck Player operation. enum DuckPlayerMode: Equatable, Codable, CustomStringConvertible, CaseIterable { case enabled, alwaysAsk, disabled private static let enabledString = "enabled" private static let alwaysAskString = "alwaysAsk" private static let neverString = "disabled" - + var description: String { switch self { case .enabled: @@ -38,7 +39,7 @@ enum DuckPlayerMode: Equatable, Codable, CustomStringConvertible, CaseIterable { return UserText.duckPlayerDisabledLabel } } - + var stringValue: String { switch self { case .enabled: @@ -50,6 +51,9 @@ enum DuckPlayerMode: Equatable, Codable, CustomStringConvertible, CaseIterable { } } + /// Initializes a `DuckPlayerMode` from a string value. + /// + /// - Parameter stringValue: The string representation of the mode. init?(stringValue: String) { switch stringValue { case Self.enabledString: @@ -64,20 +68,46 @@ enum DuckPlayerMode: Equatable, Codable, CustomStringConvertible, CaseIterable { } } +/// Protocol defining the settings for Duck Player. protocol DuckPlayerSettings: AnyObject { + /// Publisher that emits when Duck Player settings change. var duckPlayerSettingsPublisher: AnyPublisher { get } + + /// The current mode of Duck Player. var mode: DuckPlayerMode { get } + + /// Indicates if the "Always Ask" overlay has been hidden. var askModeOverlayHidden: Bool { get } + + /// Flag to allow the first video to play in Youtube var allowFirstVideo: Bool { get set } + /// Determines if Duck Player should open videos in a new tab. + var openInNewTab: Bool { get } + + /// Initializes a new instance with the provided app settings and privacy configuration manager. + /// + /// - Parameters: + /// - appSettings: The application settings. + /// - privacyConfigManager: The privacy configuration manager. init(appSettings: AppSettings, privacyConfigManager: PrivacyConfigurationManaging) + /// Sets the Duck Player mode. + /// + /// - Parameter mode: The mode to set. func setMode(_ mode: DuckPlayerMode) + + /// Sets whether the "Always Ask" overlay has been hidden. + /// + /// - Parameter overlayHidden: A Boolean indicating if the overlay is hidden. func setAskModeOverlayHidden(_ overlayHidden: Bool) + + /// Triggers a notification to update subscribers about settings changes. func triggerNotification() } +/// Default implementation of `DuckPlayerSettings`. final class DuckPlayerSettingsDefault: DuckPlayerSettings { private var appSettings: AppSettings @@ -102,6 +132,11 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { duckPlayerSettingsSubject.eraseToAnyPublisher() } + /// Initializes a new instance with the provided app settings and privacy configuration manager. + /// + /// - Parameters: + /// - appSettings: The application settings. + /// - privacyConfigManager: The privacy configuration manager. init(appSettings: AppSettings = AppDependencyProvider.shared.appSettings, privacyConfigManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager) { self.appSettings = appSettings @@ -111,6 +146,7 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { registerForNotificationChanges() } + /// DuckPlayer features are only available in these domains public struct OriginDomains { static let duckduckgo = "duckduckgo.com" static let youtubeWWW = "www.youtube.com" @@ -118,26 +154,33 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { static let youtubeMobile = "m.youtube.com" } + /// The current mode of Duck Player. var mode: DuckPlayerMode { - let experiment = DuckPlayerLaunchExperiment() - if isFeatureEnabled && experiment.isEnrolled && experiment.isExperimentCohort { + if isFeatureEnabled { return appSettings.duckPlayerMode } else { return .disabled } } + /// Indicates if the "Always Ask" overlay has been hidden. var askModeOverlayHidden: Bool { - let experiment = DuckPlayerLaunchExperiment() - if isFeatureEnabled && experiment.isEnrolled && experiment.isExperimentCohort { + if isFeatureEnabled { return appSettings.duckPlayerAskModeOverlayHidden } else { return false } } + /// Flag to allow the first video to play without redirection. var allowFirstVideo: Bool = false + /// Determines if Duck Player should open videos in a new tab. + var openInNewTab: Bool { + return appSettings.duckPlayerOpenInNewTab + } + + /// Registers a publisher to listen for changes in the privacy configuration. private func registerConfigPublisher() { isFeatureEnabledCancellable = privacyConfigManager.updatesPublisher .map { [weak privacyConfigManager] in @@ -149,6 +192,7 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { } } + /// Registers for notification changes in Duck Player settings. private func registerForNotificationChanges() { NotificationCenter.default.addObserver(self, selector: #selector(publishUpdate), @@ -156,6 +200,9 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { object: nil) } + /// Sets the Duck Player mode. + /// + /// - Parameter mode: The mode to set. func setMode(_ mode: DuckPlayerMode) { if mode != appSettings.duckPlayerMode { appSettings.duckPlayerMode = mode @@ -163,6 +210,9 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { } } + /// Sets whether the "Always Ask" overlay has been hidden. + /// + /// - Parameter overlayHidden: A Boolean indicating if the overlay is hidden. func setAskModeOverlayHidden(_ overlayHidden: Bool) { if overlayHidden != appSettings.duckPlayerAskModeOverlayHidden { appSettings.duckPlayerAskModeOverlayHidden = overlayHidden @@ -170,10 +220,14 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { } } + /// Publishes an update notification when settings change. + /// + /// - Parameter notification: The notification received. @objc private func publishUpdate(_ notification: Notification) { triggerNotification() } + /// Triggers a notification to update subscribers about settings changes. func triggerNotification() { duckPlayerSettingsSubject.send() } diff --git a/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift b/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift index b3463bbb8d..29e0bed47f 100644 --- a/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift +++ b/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift @@ -28,7 +28,7 @@ import DuckPlayer final class YoutubeOverlayUserScript: NSObject, Subfeature { - var duckPlayer: DuckPlayerProtocol + var duckPlayer: DuckPlayerControlling private var cancellables = Set() var statisticsStore: StatisticsStore private var duckPlayerStorage: DuckPlayerStorage @@ -36,7 +36,7 @@ final class YoutubeOverlayUserScript: NSObject, Subfeature { static let featureName = "duckPlayer" } - init(duckPlayer: DuckPlayerProtocol, + init(duckPlayer: DuckPlayerControlling, statisticsStore: StatisticsStore = StatisticsUserDefaults(), duckPlayerStorage: DuckPlayerStorage = DefaultDuckPlayerStorage()) { self.duckPlayer = duckPlayer diff --git a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift index 5e83db541c..f28e8a46c3 100644 --- a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift +++ b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift @@ -24,7 +24,7 @@ import Combine final class YoutubePlayerUserScript: NSObject, Subfeature { - var duckPlayer: DuckPlayerProtocol + var duckPlayer: DuckPlayerControlling private var cancellables = Set() struct Constants { @@ -40,7 +40,7 @@ final class YoutubePlayerUserScript: NSObject, Subfeature { static let telemetryEvent = "telemetryEvent" } - init(duckPlayer: DuckPlayerProtocol) { + init(duckPlayer: DuckPlayerControlling) { self.duckPlayer = duckPlayer super.init() subscribeToDuckPlayerMode() diff --git a/DuckDuckGo/Feedback/VPNFeedbackFormView.swift b/DuckDuckGo/Feedback/VPNFeedbackFormView.swift index b7562586b9..2a4469cdc6 100644 --- a/DuckDuckGo/Feedback/VPNFeedbackFormView.swift +++ b/DuckDuckGo/Feedback/VPNFeedbackFormView.swift @@ -22,7 +22,10 @@ import NetworkProtection struct VPNFeedbackFormCategoryView: View { @Environment(\.dismiss) private var dismiss - let collector = DefaultVPNMetadataCollector(statusObserver: AppDependencyProvider.shared.connectionObserver) + let collector = DefaultVPNMetadataCollector( + statusObserver: AppDependencyProvider.shared.connectionObserver, + serverInfoObserver: AppDependencyProvider.shared.serverInfoObserver + ) var body: some View { VStack { diff --git a/DuckDuckGo/Feedback/VPNMetadataCollector.swift b/DuckDuckGo/Feedback/VPNMetadataCollector.swift index a2f6b69669..f77dcfddd2 100644 --- a/DuckDuckGo/Feedback/VPNMetadataCollector.swift +++ b/DuckDuckGo/Feedback/VPNMetadataCollector.swift @@ -107,7 +107,7 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { private let defaults: UserDefaults init(statusObserver: ConnectionStatusObserver, - serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession(), + serverInfoObserver: ConnectionServerInfoObserver, accountManager: AccountManager = AppDependencyProvider.shared.subscriptionManager.accountManager, settings: VPNSettings = .init(defaults: .networkProtectionGroupDefaults), defaults: UserDefaults = .networkProtectionGroupDefaults) { @@ -277,7 +277,10 @@ extension VPNMetadata: UnifiedFeedbackMetadata {} extension DefaultVPNMetadataCollector: UnifiedMetadataCollector { convenience init() { - self.init(statusObserver: AppDependencyProvider.shared.connectionObserver) + self.init( + statusObserver: AppDependencyProvider.shared.connectionObserver, + serverInfoObserver: AppDependencyProvider.shared.serverInfoObserver + ) } func collectMetadata() async -> VPNMetadata? { diff --git a/DuckDuckGo/NetworkProtectionDebugViewController.swift b/DuckDuckGo/NetworkProtectionDebugViewController.swift index 9af0df4f9f..0c0c34568e 100644 --- a/DuckDuckGo/NetworkProtectionDebugViewController.swift +++ b/DuckDuckGo/NetworkProtectionDebugViewController.swift @@ -643,7 +643,10 @@ shouldShowVPNShortcut: \(vpnVisibility.shouldShowVPNShortcut() ? "YES" : "NO") @MainActor private func refreshMetadata() async { - let collector = DefaultVPNMetadataCollector(statusObserver: AppDependencyProvider.shared.connectionObserver) + let collector = DefaultVPNMetadataCollector( + statusObserver: AppDependencyProvider.shared.connectionObserver, + serverInfoObserver: AppDependencyProvider.shared.serverInfoObserver + ) self.vpnMetadata = await collector.collectMetadata() self.tableView.reloadData() } diff --git a/DuckDuckGo/OmniBar.swift b/DuckDuckGo/OmniBar.swift index 61850c6f2c..c2b6877be7 100644 --- a/DuckDuckGo/OmniBar.swift +++ b/DuckDuckGo/OmniBar.swift @@ -263,6 +263,7 @@ class OmniBar: UIView { let icon = PrivacyIconLogic.privacyIcon(for: url) privacyInfoContainer.privacyIcon.updateIcon(icon) + customIconView.isHidden = true } public func updatePrivacyIcon(for privacyInfo: PrivacyInfo?) { @@ -275,11 +276,11 @@ class OmniBar: UIView { showCustomIcon(icon: .duckPlayer) return } - - customIconView.isHidden = true + privacyInfoContainer.privacyIcon.isHidden = privacyInfo.isSpecialErrorPageVisible let icon = PrivacyIconLogic.privacyIcon(for: privacyInfo) privacyInfoContainer.privacyIcon.updateIcon(icon) + customIconView.isHidden = true } // Support static custom icons, for things like internal pages, for example diff --git a/DuckDuckGo/RootDebugViewController.swift b/DuckDuckGo/RootDebugViewController.swift index 08dea4d8ae..0283c332dd 100644 --- a/DuckDuckGo/RootDebugViewController.swift +++ b/DuckDuckGo/RootDebugViewController.swift @@ -47,9 +47,6 @@ class RootDebugViewController: UITableViewController { case newTabPageSections = 674 case onboarding = 676 case resetSyncPromoPrompts = 677 - case resetDuckPlayerExperiment = 678 - case overrideDuckPlayerExperiment = 679 - case overrideDuckPlayerExperimentControl = 680 case resetTipKit = 681 } @@ -191,17 +188,8 @@ class RootDebugViewController: UITableViewController { let syncPromoPresenter = SyncPromoManager(syncService: sync) syncPromoPresenter.resetPromos() ActionMessageView.present(message: "Sync Promos reset") - case .resetDuckPlayerExperiment: - DuckPlayerLaunchExperiment().cleanup() - ActionMessageView.present(message: "Experiment Settings deleted. You'll be assigned a random cohort") case .resetTipKit: tipKitUIActionHandler?.resetTipKitTapped() - case .overrideDuckPlayerExperiment: - DuckPlayerLaunchExperiment().override() - ActionMessageView.present(message: "Overriding experiment. You are now in the 'experiment' group. Restart the app to complete") - case .overrideDuckPlayerExperimentControl: - DuckPlayerLaunchExperiment().override(control: true) - ActionMessageView.present(message: "Overriding experiment. You are now in the 'control' group. Restart the app to complete") } } } diff --git a/DuckDuckGo/SettingsMainSettingsView.swift b/DuckDuckGo/SettingsMainSettingsView.swift index b851cda021..02b487031b 100644 --- a/DuckDuckGo/SettingsMainSettingsView.swift +++ b/DuckDuckGo/SettingsMainSettingsView.swift @@ -70,12 +70,10 @@ struct SettingsMainSettingsView: View { // Duck Player // We need to hide the settings until the user is enrolled in the experiment - if DuckPlayerLaunchExperiment().isEnrolled && DuckPlayerLaunchExperiment().isExperimentCohort { - if viewModel.isInternalUser || viewModel.state.duckPlayerEnabled { - NavigationLink(destination: SettingsDuckPlayerView().environmentObject(viewModel)) { - SettingsCellView(label: UserText.duckPlayerFeatureName, - image: Image("SettingsDuckPlayer")) - } + if viewModel.state.duckPlayerEnabled { + NavigationLink(destination: SettingsDuckPlayerView().environmentObject(viewModel)) { + SettingsCellView(label: UserText.duckPlayerFeatureName, + image: Image("SettingsDuckPlayer")) } } } diff --git a/DuckDuckGo/Subscription/SubscriptionCookieManageEventPixelMapping.swift b/DuckDuckGo/Subscription/SubscriptionCookieManageEventPixelMapping.swift index 19efdba9dd..0ec0ee2ef9 100644 --- a/DuckDuckGo/Subscription/SubscriptionCookieManageEventPixelMapping.swift +++ b/DuckDuckGo/Subscription/SubscriptionCookieManageEventPixelMapping.swift @@ -30,12 +30,10 @@ public final class SubscriptionCookieManageEventPixelMapping: EventMapping