diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5622a3bd47..b4a42164b1 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -953,7 +953,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 */; }; @@ -1009,7 +1008,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 */; }; @@ -2778,7 +2776,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 = ""; }; @@ -2830,7 +2827,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 = ""; }; @@ -5297,7 +5293,6 @@ D6B67A112C332B6E002122EB /* DuckPlayerMocks.swift */, D62EC3BB2C2470E000FC9D04 /* DuckPlayerTests.swift */, D62EC3B82C246A5600FC9D04 /* YoutublePlayerNavigationHandlerTests.swift */, - D6F557B92C8859040034444B /* DuckPlayerExperimentTests.swift */, ); name = DuckPlayer; sourceTree = ""; @@ -5314,7 +5309,6 @@ D63FF8942C1B67E8006DE24D /* YoutubeOverlayUserScript.swift */, D63FF8932C1B67E8006DE24D /* YoutubePlayerUserScript.swift */, 31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */, - D60E5C2E2C862297007D6BC7 /* DuckPlayerLaunchExperiment.swift */, ); path = DuckPlayer; sourceTree = ""; @@ -7785,7 +7779,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 */, @@ -7932,7 +7925,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 */, @@ -10957,8 +10949,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { - kind = exactVersion; - version = 200.0.0; + branch = daniel/update.duckplayer.host; + kind = branch; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3a477518cb..78fd860a36 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "9f62aacd878a0c05bff7256eb25b8776aa4e917f", - "version" : "200.0.0" + "branch" : "daniel/update.duckplayer.host", + "revision" : "86be3f4f4b792dd5a645f1fcbd0058c5823892cf" } }, { diff --git a/DuckDuckGo/Debug.storyboard b/DuckDuckGo/Debug.storyboard index c66b2130dc..616abe7fd8 100644 --- a/DuckDuckGo/Debug.storyboard +++ b/DuckDuckGo/Debug.storyboard @@ -374,33 +374,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1049,34 +1022,34 @@ - + - + - + - + diff --git a/DuckDuckGo/DuckPlayer/DuckPlayer.swift b/DuckDuckGo/DuckPlayer/DuckPlayer.swift index 55e9d907a5..66d0163443 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayer.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayer.swift @@ -184,11 +184,7 @@ final class DuckPlayer: DuckPlayerProtocol { } 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 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..ca914cff5c 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift @@ -27,14 +27,14 @@ import DuckPlayer import os.log final class DuckPlayerNavigationHandler { - + var duckPlayer: DuckPlayerProtocol var referrer: DuckPlayerReferrer = .other - var lastHandledVideoID: String? + var renderedVideoID: String? + var renderedURL: URL? var featureFlagger: FeatureFlagger var appSettings: AppSettings var navigationType: WKNavigationType = .other - var experiment: DuckPlayerLaunchExperimentHandling private lazy var internalUserDecider = AppDependencyProvider.shared.internalUserDecider private struct Constants { @@ -53,16 +53,17 @@ final class DuckPlayerNavigationHandler { static let urlInternalReferrer = "embeds_referring_euri" static let youtubeScheme = "youtube://" static let duckPlayerScheme = URL.NavigationalScheme.duck.rawValue + static let duckPlayerHeaderKey = "X-Navigation-Source" + static let duckPlayerHeaderValue = "DuckPlayer" + static let duckPlayerReferrerHeaderKey = "X-Navigation-DuckPlayerReferrer" } init(duckPlayer: DuckPlayerProtocol = DuckPlayer(), featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger, - appSettings: AppSettings, - experiment: DuckPlayerLaunchExperimentHandling = DuckPlayerLaunchExperiment()) { + appSettings: AppSettings) { self.duckPlayer = duckPlayer self.featureFlagger = featureFlagger self.appSettings = appSettings - self.experiment = experiment } static var htmlTemplatePath: String { @@ -109,61 +110,10 @@ final class DuckPlayerNavigationHandler { } private var duckPlayerMode: DuckPlayerMode { - let isEnabled = experiment.isEnrolled && experiment.isExperimentCohort && featureFlagger.isFeatureOn(.duckPlayer) + let isEnabled = featureFlagger.isFeatureOn(.duckPlayer) return isEnabled ? duckPlayer.settings.mode : .disabled } - // 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 - } - } - - // 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) - } - private var isYouTubeAppInstalled: Bool { if let youtubeURL = URL(string: Constants.youtubeScheme) { return UIApplication.shared.canOpenURL(youtubeURL) @@ -181,10 +131,6 @@ final class DuckPlayerNavigationHandler { return false } - private func isOpenInYoutubeURL(url: URL) -> Bool { - return isWatchInYouTubeURL(url: url) - } - private func getYoutubeURLFromOpenInYoutubeLink(url: URL) -> URL? { guard isWatchInYouTubeURL(url: url), let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), @@ -212,37 +158,51 @@ final class DuckPlayerNavigationHandler { // Parse openInYoutubeURL if present let newURL = getYoutubeURLFromOpenInYoutubeLink(url: url) ?? url - guard let (videoID, _) = newURL.youtubeVideoParams else { return } - // If this is a SERP link, set the referrer accordingly if let navigationAction, isSERPLink(navigationAction: navigationAction) { referrer = .serp } + + } + + // Validates a duck:// url and loads it + private func redirectToDuckPlayerVideo(url: URL?, webView: WKWebView) { + guard let url, + let (videoID, _) = url.youtubeVideoParams else { return } - 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 - } - } - - experiment.fireYoutubePixel(videoID: videoID) + renderedURL = url + renderedVideoID = videoID + let duckPlayerURL = URL.duckPlayer(videoID) + Logger.duckPlayer.debug("DP: Redirecting to DuckPlayer Video: \(duckPlayerURL.absoluteString)") + loadWithDuckPlayerHeaders(URLRequest(url: duckPlayerURL), referrer: referrer, webView: webView) + + } + + // Validates a youtube watch URL and loads it + private func redirectToYouTubeVideo(url: URL?, webView: WKWebView) { + guard let url, + let (videoID, _) = url.youtubeVideoParams else { return } + + var redirectURL = url + + // Parse OpenInYouTubeURLs if present + if let parsedURL = getYoutubeURLFromOpenInYoutubeLink(url: url) { + redirectURL = parsedURL + } + duckPlayer.settings.allowFirstVideo = true + renderedVideoID = videoID + if let finalURL = redirectURL.addingWatchInYoutubeQueryParameter() { + loadWithDuckPlayerHeaders(URLRequest(url: redirectURL), referrer: referrer, webView: webView) + } + } + + // Performs a simple back/forward navigation + private func performBackForwardNavigation(webView: WKWebView, direction: DuckPlayerNavigationDirection) { + if direction == .back { + webView.goBack() + } else { + webView.goForward() } - } // Determines if the link should be opened in a new tab @@ -269,244 +229,236 @@ final class DuckPlayerNavigationHandler { } } + // Replaces webView.load to add DuckPlayer headers, used for navigation + func loadWithDuckPlayerHeaders(_ request: URLRequest, referrer: DuckPlayerReferrer, webView: WKWebView) { + + var newRequest = request + + newRequest.addValue("DuckPlayer", forHTTPHeaderField: DuckPlayerNavigationHandler.Constants.duckPlayerHeaderKey) + newRequest.addValue(referrer.stringValue, forHTTPHeaderField: DuckPlayerNavigationHandler.Constants.duckPlayerReferrerHeaderKey) + + webView.load(newRequest) + } + } extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling { - - // Handle rendering the simulated request if the URL is duck:// - // and DuckPlayer is either enabled or alwaysAsk + + // Handle rendering the simulated request for duck:// links @MainActor func handleNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) { + + Logger.duckPlayer.debug("Handling Navigation for \(navigationAction.request.url?.absoluteString ?? "")") - Logger.duckPlayer.debug("Handling DuckPlayer Player Navigation for \(navigationAction.request.url?.absoluteString ?? "")") + duckPlayer.settings.allowFirstVideo = false // Disable overlay for first video - // 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 + + // Redirect to YouTube if DuckPlayer is disabled + guard featureFlagger.isFeatureOn(.duckPlayer) && duckPlayer.settings.mode != .disabled else { + if let (videoID, _) = url.youtubeVideoParams { + loadWithDuckPlayerHeaders(URLRequest(url: URL.youtube(videoID)), referrer: referrer, webView: webView) + } + 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 + // Handle "open in YouTube" links (duck://player/openInYoutube) + if let newURL = getYoutubeURLFromOpenInYoutubeLink(url: url), + let (videoID, _) = newURL.youtubeVideoParams { + + duckPlayer.settings.allowFirstVideo = true // Always skip overlay for these links - // 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) + // 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 { - webView.load(URLRequest(url: newURL)) + redirectToYouTubeVideo(url: newURL, 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]) - } - - // Pixel for Views From Youtube - if referrer == .youtube, - duckPlayerMode == .enabled { - Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromYoutubeAutomatic) - } - - if url.isDuckURLScheme { - - // If DuckPlayer is Enabled or in ask mode, render the video + + // Handle duck:// scheme URLs + if url.isDuckURLScheme, + let (videoID, _) = url.youtubeVideoParams { + + // Simulate DuckPlayer request if in enabled/ask mode and not redirected to YouTube if duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk, !url.hasWatchInYoutubeQueryParameter { let newRequest = Self.makeDuckPlayerRequest(from: URLRequest(url: url)) + Logger.duckPlayer.debug("DP: Loading Simulated Request for \(url.absoluteString)") - 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) { + webView.stopLoading() self.performRequest(request: newRequest, webView: webView) + self.renderedVideoID = videoID } - - // 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) + } + } } - // DecidePolicyFor handler to redirect relevant requests - // to duck://player @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 ?? "")") + Logger.duckPlayer.debug("DP: Initializing Navigation handler for URL: \(webView.url?.absoluteString ?? "No URL")") - // 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 + // Check if DuckPlayer feature is ON + guard featureFlagger.isFeatureOn(.duckPlayer) else { + Logger.duckPlayer.debug("DP: Feature flag is off, skipping") + return .notHandled(.featureOff) } - guard let url = navigationAction.request.url else { - completion(.cancel) - return + // Check if the URL is a DuckPlayer URL (handled elsewhere) + guard !(webView.url?.isDuckURLScheme ?? false) else { + return .notHandled(.isAlreadyDuckAddress) } - guard featureFlagger.isFeatureOn(.duckPlayer) else { - completion(.allow) - return + // If the URL hasn't changed, exit + guard webView.url != renderedURL else { + Logger.duckPlayer.debug("DP: URL has not changed, skipping") + return .notHandled(.urlHasNotChanged) } - // This is passed to the FE overlay at init to disable the overlay for one video - duckPlayer.settings.allowFirstVideo = false + // Disable the Youtube Overlay for Player links + // Youtube player links should open the video in Youtube + // without overlay + if let url = webView.url, url.hasWatchInYoutubeQueryParameter { + duckPlayer.settings.allowFirstVideo = true + return .notHandled(.disabledForNextVideo) + } - if let (videoID, _) = url.youtubeVideoParams, - videoID == lastHandledVideoID, - !url.hasWatchInYoutubeQueryParameter { - Logger.duckPlayer.debug("DP: DecidePolicy: URL (\(url.absoluteString)) already handled, skipping") - completion(.cancel) - return + // Ensure DuckPlayer is active + guard duckPlayer.settings.mode == .enabled else { + Logger.duckPlayer.debug("DP: DuckPlayer is Disabled, skipping") + return .notHandled(.duckPlayerDisabled) } - // 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 + // Update rendered URL and handle YouTube-specific actions + if let url = webView.url { + renderedURL = url + referrer = url.isYoutube ? .youtube : .other - if duckPlayerMode == .enabled, !url.isDuckPlayer { - Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromSERP, debounce: 2) + if url.isYoutubeVideo { + handleYouTubePageVisited(url: url, navigationAction: nil) } - - } else { - Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromOther, debounce: 2) } - - 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 + // Check for valid YouTube video parameters + guard let url = webView.url, + let (videoID, _) = url.youtubeVideoParams else { + Logger.duckPlayer.debug("DP: No video parameters present in the URL, skipping") + renderedVideoID = nil + return .notHandled(.videoIDNotPresent) } - completion(.allow) - } - - @MainActor - func handleJSNavigation(url: URL?, webView: WKWebView) { - - Logger.duckPlayer.debug("Handling JS Navigation for \(url?.absoluteString ?? "")") - - guard featureFlagger.isFeatureOn(.duckPlayer) else { - return + // If the video has already been rendered, exit + guard renderedVideoID != videoID else { + Logger.duckPlayer.debug("DP: Video already rendered, skipping") + return .notHandled(.videoAlreadyHandled) } - // 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) + // If DuckPlayer is disabled for the next video, skip handling and reset + if duckPlayer.settings.allowFirstVideo { + duckPlayer.settings.allowFirstVideo = false + Logger.duckPlayer.debug("DP: Skipping video, DuckPlayer disabled for the next video") + renderedVideoID = videoID + return .notHandled(.disabledForNextVideo) } + + // Finally, handle the redirection to DuckPlayer + Logger.duckPlayer.debug("DP: Handling navigation for \(webView.url?.absoluteString ?? "No URL")") + redirectToDuckPlayerVideo(url: url, webView: webView) + return .handled } + @MainActor - func handleGoBack(webView: WKWebView) { + func handleBackForwardNavigation(webView: WKWebView, direction: DuckPlayerNavigationDirection) { - Logger.duckPlayer.debug("DP: Handling Back Navigation") + // Reset DuckPlayer status + duckPlayer.settings.allowFirstVideo = false - let experiment = DuckPlayerLaunchExperiment() - let duckPlayerMode = experiment.isExperimentCohort ? duckPlayerMode : .disabled + Logger.duckPlayer.debug("DP: Handling \(direction == .back ? "Back" : "Forward") Navigation") + // Check if the DuckPlayer feature is enabled guard featureFlagger.isFeatureOn(.duckPlayer) else { - webView.goBack() + performBackForwardNavigation(webView: webView, direction: direction) return } - lastHandledVideoID = nil - webView.stopLoading() - - // Check if the back list has items - guard !webView.backForwardList.backList.isEmpty else { - webView.goBack() + // Check if the list has items in the desired direction + let navigationList = direction == .back ? webView.backForwardList.backList : webView.backForwardList.forwardList + guard !navigationList.isEmpty else { + performBackForwardNavigation(webView: webView, direction: direction) 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)") - webView.go(to: nonYoutubeItem) + + // If we are not at DuckPlayer, just perform the navigation + if !(webView.url?.isDuckPlayer ?? false) { + performBackForwardNavigation(webView: webView, direction: direction) + } else { - Logger.duckPlayer.debug("DP: Navigating back to previous page") - webView.goBack() + // We may need to skip the YouTube video already rendered in DuckPlayer + guard let (listVideoID, _) = (direction == .back ? navigationList.reversed().first : navigationList.first)?.url.youtubeVideoParams, + let (currentVideoID, _) = webView.url?.youtubeVideoParams, + duckPlayer.settings.mode != .disabled else { + performBackForwardNavigation(webView: webView, direction: direction) + return + } + + // Check if the current and previous/next video IDs match + if listVideoID == currentVideoID { + let reversedList = navigationList.reversed() + let nextIndex = reversedList.index(reversedList.startIndex, offsetBy: direction == .back ? 1 : 0) + + if nextIndex < reversedList.endIndex { + webView.go(to: reversedList[nextIndex]) + } else { + performBackForwardNavigation(webView: webView, direction: direction) + } + } else { + performBackForwardNavigation(webView: webView, direction: direction) + } } } + // Handle Reload for DuckPlayer Videos @MainActor func handleReload(webView: WKWebView) { Logger.duckPlayer.debug("DP: Handling Reload") - + + // Reset DuckPlayer status + duckPlayer.settings.allowFirstVideo = false + renderedVideoID = nil + renderedURL = nil + guard featureFlagger.isFeatureOn(.duckPlayer) 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))) + redirectToDuckPlayerVideo(url: url, webView: webView) } else { webView.reload() } @@ -517,6 +469,9 @@ extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling { Logger.duckPlayer.debug("DP: Attach WebView") + // Reset DuckPlayer status + duckPlayer.settings.allowFirstVideo = false + guard featureFlagger.isFeatureOn(.duckPlayer) else { return } @@ -530,12 +485,22 @@ extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling { } + // 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) + } + // 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) } @@ -563,10 +528,40 @@ extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling { return false } + // Determine if navigation should be cancelled + // This is to be used in DecidePolicy For to prevent the webView + // from opening the Youtube app on user-triggered links + @MainActor + func shouldCancelNavigation(navigationAction: WKNavigationAction, webView: WKWebView) -> Bool { + + // Check if the custom "X-Navigation-Source" header is present + if let headers = navigationAction.request.allHTTPHeaderFields, + let navigationSource = headers[Constants.duckPlayerHeaderKey], + navigationSource == Constants.duckPlayerHeaderValue { + return false + } + + // Otherwise, validate if the page is a Youtube page, and DuckPlayer is Enabled + if featureFlagger.isFeatureOn(.duckPlayer), + duckPlayer.settings.mode != .disabled, + let url = navigationAction.request.url, + url.isYoutube || url.isYoutubeWatch { + + // Cancel the current loading and load with DuckPlayer headers + webView.stopLoading() + loadWithDuckPlayerHeaders(navigationAction.request, referrer: referrer, webView: webView) + return true + } + + // Allow all other navigations + return false + } + } extension WKWebView { var isEmptyTab: Bool { return self.url == nil || self.url?.absoluteString == "about:blank" } + } diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift index 2c9ce16bd0..8875e2cc2b 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift @@ -20,25 +20,49 @@ import WebKit enum DuckPlayerNavigationEvent { - case youtubeVideoPageVisited case JSTriggeredNavigation } +enum DuckPlayerNavigationType: String, Codable { + case javascript, + direct +} + +enum DuckPlayerNavigationHandlerURLChangeResult { + + enum HandlingResult { + case featureOff + case duckPlayerDisabled + case isAlreadyDuckAddress + case urlHasNotChanged + case videoIDNotPresent + case videoAlreadyHandled + case disabledForNextVideo + } + + case handled + case notHandled(HandlingResult) +} + +enum DuckPlayerNavigationDirection { + case back, forward +} + protocol DuckPlayerNavigationHandling: AnyObject { 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) - func handleGoBack(webView: WKWebView) + func handleNavigation(_ navigationAction: WKNavigationAction, + webView: WKWebView) + func handleURLChange(webView: WKWebView) -> DuckPlayerNavigationHandlerURLChangeResult + func handleBackForwardNavigation(webView: WKWebView, direction: DuckPlayerNavigationDirection) func handleReload(webView: WKWebView) func handleAttach(webView: WKWebView) func getDuckURLFor(_ url: URL) -> URL func handleEvent(event: DuckPlayerNavigationEvent, url: URL?, navigationAction: WKNavigationAction?) + func shouldCancelNavigation(navigationAction: WKNavigationAction, webView: WKWebView) -> Bool func shouldOpenInNewTab(_ navigationAction: WKNavigationAction, webView: WKWebView) -> Bool + } diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift b/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift index c650284fcc..2701cf161b 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift @@ -119,8 +119,7 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { } var mode: DuckPlayerMode { - let experiment = DuckPlayerLaunchExperiment() - if isFeatureEnabled && experiment.isEnrolled && experiment.isExperimentCohort { + if isFeatureEnabled { return appSettings.duckPlayerMode } else { return .disabled @@ -128,8 +127,7 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { } var askModeOverlayHidden: Bool { - let experiment = DuckPlayerLaunchExperiment() - if isFeatureEnabled && experiment.isEnrolled && experiment.isExperimentCohort { + if isFeatureEnabled { return appSettings.duckPlayerAskModeOverlayHidden } else { return false diff --git a/DuckDuckGo/RootDebugViewController.swift b/DuckDuckGo/RootDebugViewController.swift index 23500ebf8c..8cdfbcbe44 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 } @IBOutlet weak var shareButton: UIBarButtonItem! @@ -184,15 +181,6 @@ 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 .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..9ce2eca5cc 100644 --- a/DuckDuckGo/SettingsMainSettingsView.swift +++ b/DuckDuckGo/SettingsMainSettingsView.swift @@ -68,16 +68,14 @@ struct SettingsMainSettingsView: View { image: Image("SettingsDataClearing")) } - // 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")) - } + // Duck Player + if viewModel.isInternalUser || viewModel.state.duckPlayerEnabled { + NavigationLink(destination: SettingsDuckPlayerView().environmentObject(viewModel)) { + SettingsCellView(label: UserText.duckPlayerFeatureName, + image: Image("SettingsDuckPlayer")) } } + } } diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 772bc1e760..72f64327eb 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -248,6 +248,8 @@ class TabViewController: UIViewController { let finalURL = duckPlayerNavigationHandler?.getDuckURLFor(url) ?? url let activeLink = Link(title: title, url: finalURL) + + guard let storedLink = tabModel.link else { return activeLink } @@ -581,10 +583,10 @@ class TabViewController: UIViewController { self?.load(urlRequest: cleanURLRequest) - if let handler = self?.duckPlayerNavigationHandler, + if let handler = self?.duckPlayerNavigationHandler, let webView = self?.webView { handler.handleAttach(webView: webView) - } + } }) } @@ -728,7 +730,12 @@ class TabViewController: UIViewController { progressWorker.progressDidChange(webView.estimatedProgress) case #keyPath(WKWebView.url): - webViewUrlHasChanged() + // A short delay is required here, because the URL takes some time + // to propagate through the webView.url property and might not + // be immediately available in the observer + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.webViewUrlHasChanged() + } case #keyPath(WKWebView.canGoBack): delegate?.tabLoadingStateDidChange(tab: self) @@ -745,37 +752,18 @@ class TabViewController: UIViewController { } func webViewUrlHasChanged() { + + // Handle DuckPlayer Navigation URL changes + if let handler = duckPlayerNavigationHandler, + let url = webView.url, + case .handled = handler.handleURLChange(webView: webView) { + return + } + if url == nil { url = webView.url } else if let currentHost = url?.host, let newHost = webView.url?.host, currentHost == newHost { url = webView.url - - // decideForPolicy is not called for JS navigation - // This ensures DuckPlayer works on internal JS navigation based on - // URL Changes - - if let url, - url.isYoutubeVideo { - - duckPlayerNavigationHandler?.handleEvent(event: .youtubeVideoPageVisited, - url: url, - navigationAction: nil) - - if duckPlayerNavigationHandler?.duckPlayer.settings.mode == .enabled { - duckPlayerNavigationHandler?.handleJSNavigation(url: url, webView: webView) - } - } - - - } - if let url { - duckPlayerNavigationHandler?.referrer = url.isYoutube ? .youtube : .other - - // Open in new tab if required - // If the lastRenderedURL is nil, it means we're already in a new tab - if webView.url != nil && lastRenderedURL != nil { - duckPlayerNavigationHandler?.handleEvent(event: .JSTriggeredNavigation, url: webView.url, navigationAction: nil) - } } } @@ -846,11 +834,11 @@ class TabViewController: UIViewController { func goBack() { dismissJSAlertIfNeeded() - + if let url = url, url.isDuckPlayer { webView.stopLoading() if webView.canGoBack { - duckPlayerNavigationHandler?.handleGoBack(webView: webView) + duckPlayerNavigationHandler?.handleBackForwardNavigation(webView: webView, direction: .back) chromeDelegate?.omniBar.resignFirstResponder() return } @@ -882,7 +870,20 @@ class TabViewController: UIViewController { func goForward() { dismissJSAlertIfNeeded() - + + if let url = url, url.isDuckPlayer { + webView.stopLoading() + if webView.canGoBack { + duckPlayerNavigationHandler?.handleBackForwardNavigation(webView: webView, direction: .forward) + chromeDelegate?.omniBar.resignFirstResponder() + return + } + if openingTab != nil { + delegate?.tabDidRequestClose(self) + return + } + } + if webView.goForward() != nil { chromeDelegate?.omniBar.resignFirstResponder() } @@ -1726,6 +1727,14 @@ extension TabViewController: WKNavigationDelegate { } } + // Prevent the YouTube app from intercepting + // links based on DuckPlayer settings + if let handler = duckPlayerNavigationHandler, + handler.shouldCancelNavigation(navigationAction: navigationAction, webView: webView) { + decisionHandler(.cancel) + return + } + if let url = navigationAction.request.url, !url.isDuckDuckGoSearch, true == shouldWaitUntilContentBlockingIsLoaded({ [weak self, webView /* decision handler must be called */] in @@ -1822,9 +1831,6 @@ extension TabViewController: WKNavigationDelegate { if url.isDuckDuckGoSearch { StatisticsLoader.shared.refreshSearchRetentionAtb() privacyProDataReporter.saveSearchCount() - - // Duck Player Search Experiment - DuckPlayerLaunchExperiment(duckPlayerMode: duckPlayer?.settings.mode).fireSearchPixels() } self.delegate?.closeFindInPage(tab: self) @@ -1882,23 +1888,6 @@ extension TabViewController: WKNavigationDelegate { adClickAttributionLogic.onBackForwardNavigation(mainFrameURL: webView.url) } - if navigationAction.isTargetingMainFrame(), - url.isYoutubeVideo { - - duckPlayerNavigationHandler?.handleEvent(event: .youtubeVideoPageVisited, - url: url, - navigationAction: navigationAction) - - // Handle decidePolicy For - if duckPlayerNavigationHandler?.duckPlayer.settings.mode == .enabled { - duckPlayerNavigationHandler?.handleDecidePolicyFor(navigationAction, - completion: completion, - webView: webView) - return - } - - } - let schemeType = SchemeHandler.schemeType(for: url) self.blobDownloadTargetFrame = nil switch schemeType { @@ -1916,9 +1905,6 @@ extension TabViewController: WKNavigationDelegate { performBlobNavigation(navigationAction, completion: completion) case .duck: - duckPlayerNavigationHandler?.handleEvent(event: .youtubeVideoPageVisited, - url: url, - navigationAction: navigationAction) // Validate Duck Player setting to open in new tab or locally if duckPlayerNavigationHandler?.shouldOpenInNewTab(navigationAction, webView: webView) ?? false { diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index b8fd72171c..46a4693f47 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1275,7 +1275,7 @@ But if you *do* want a peek under the hood, you can find more information about public static let duckPlayerAskLabel = NSLocalizedString("duckPlayer.ask.label", value: "Ask every time", comment: "Text displayed when DuckPlayer is in 'Ask' mode.") public static let duckPlayerDisabledLabel = NSLocalizedString("duckPlayer.never.label", value: "Never", comment: "Text displayed when DuckPlayer is in off.") - public static let settingsOpenVideosInDuckPlayerLabel = NSLocalizedString("duckplayer.settings.open-videos-in", value: "Open Videos in Duck Player", comment: "Settings screen cell text for DuckPlayer settings") + public static let settingsOpenVideosInDuckPlayerLabel = NSLocalizedString("duckplayer.settings.open-videos-in", value: "Open YouTube videos in Duck Player", comment: "Settings screen cell text for DuckPlayer settings") public static let duckPlayerFeatureName = NSLocalizedString("duckplayer.settings.title", value: "Duck Player", comment: "Settings screen cell text for DuckPlayer settings") public static let settingsOpenDuckPlayerNewTabLabel = NSLocalizedString("duckplayer.settings.open-new-tab-label", value: "Open Duck Player in a new tab", comment: "Settings screen cell text for DuckPlayer settings to open in new tab") diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 04e1c6f25f..649ace1bea 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -1053,7 +1053,7 @@ "duckplayer.settings.open-new-tab-label" = "Open Duck Player in a new tab"; /* Settings screen cell text for DuckPlayer settings */ -"duckplayer.settings.open-videos-in" = "Open Videos in Duck Player"; +"duckplayer.settings.open-videos-in" = "Open YouTube videos in Duck Player"; /* Settings screen cell text for DuckPlayer settings */ "duckplayer.settings.title" = "Duck Player"; diff --git a/DuckDuckGoTests/DuckPlayerExperimentTests.swift b/DuckDuckGoTests/DuckPlayerExperimentTests.swift deleted file mode 100644 index 56040b0dee..0000000000 --- a/DuckDuckGoTests/DuckPlayerExperimentTests.swift +++ /dev/null @@ -1,435 +0,0 @@ -// -// DuckPlayerExperimentTests.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 XCTest -@testable import DuckDuckGo -import Core - -public class MockDuckPlayerExperimentDateProvider: DuckPlayerExperimentDateProvider { - private var customDate: Date? - - public var currentDate: Date { - return customDate ?? Date() - } - - public init(customDate: Date? = nil) { - self.customDate = customDate - } - - public func setCurrentDate(_ date: Date) { - self.customDate = date - } - - public func resetToCurrentDate() { - self.customDate = nil - } -} - - -final class DuckPlayerExperimentPixelFireMock: DuckPlayerExperimentPixelFiring { - - static private(set) var capturedPixelEventHistory: [(pixel: Pixel.Event, params: [String: String])] = [] - - static func fireDuckPlayerExperimentPixel(pixel: Pixel.Event, withAdditionalParameters params: [String: String]) { - capturedPixelEventHistory.append((pixel: pixel, params: params)) - } - - static func tearDown() { - capturedPixelEventHistory = [] - } -} - - -final class DuckPlayerExperimentDailyPixelFireMock: DuckPlayerExperimentPixelFiring { - - static private(set) var capturedPixelEventHistory: [(pixel: Pixel.Event, params: [String: String])] = [] - - static func fireDuckPlayerExperimentPixel(pixel: Pixel.Event, withAdditionalParameters params: [String: String]) { - capturedPixelEventHistory.append((pixel: pixel, params: params)) - } - - static func tearDown() { - capturedPixelEventHistory = [] - } -} - - -final class DuckPlayerLaunchExperimentTests: XCTestCase { - - private var sut: DuckPlayerLaunchExperiment! - private var userDefaults: UserDefaults! - private var dateProvider = MockDuckPlayerExperimentDateProvider() - - override func setUp() { - super.setUp() - // Setting up a temporary UserDefaults to isolate tests - userDefaults = UserDefaults(suiteName: "DuckPlayerLaunchExperimentTests") - userDefaults.removePersistentDomain(forName: "DuckPlayerLaunchExperimentTests") - } - - override func tearDown() { - sut = nil - userDefaults = nil - DuckPlayerExperimentPixelFireMock.tearDown() - DuckPlayerExperimentDailyPixelFireMock.tearDown() - super.tearDown() - } - - func testAssignUserToCohort_AssignsCohotsAndFiresPixels() { - - // Set a fixed date to 2024.09.10 - dateProvider.setCurrentDate(Date(timeIntervalSince1970: 1725926400)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - - sut.cleanup() - XCTAssertFalse(sut.isEnrolled, "User should not be enrolled initially.") - - sut.assignUserToCohort() - - XCTAssertTrue(sut.isEnrolled, "User should be enrolled after assigning to cohort.") - XCTAssertNotNil(sut.experimentCohortV2, "Experiment cohort should be assigned.") - XCTAssertNotNil(sut.enrollmentDateV2, "Enrollment date should be set.") - XCTAssertEqual(DuckPlayerLaunchExperiment.formattedDate(sut.enrollmentDateV2 ?? Date()), "20240910", "The assigned date should match.") - - // Check the pixel event history - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - XCTAssertEqual(history.count, 1, "One pixel event should be fired.") - if let firstEvent = history.first { - XCTAssertEqual(firstEvent.pixel, .duckplayerExperimentCohortAssign, "Enrollment pixel should be duckplayerExperimentCohortAssign") - XCTAssert(["control", "experiment"].contains(firstEvent.params["variant"]), "The variant is incorrect") - XCTAssertEqual(firstEvent.params["enrollment"], "20240910", "The assigned date should be valid.") - } - } - - func testAssignUserToCohortMultipleTimes_DoesNotReassignNorFiresMultiplePixels() { - - // Set a fixed date to 2024.09.10 - dateProvider.setCurrentDate(Date(timeIntervalSince1970: 1725926400)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - - sut.cleanup() - XCTAssertFalse(sut.isEnrolled, "User should not be enrolled initially.") - - sut.assignUserToCohort() - XCTAssertEqual(DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory.count, 1, "Enrollment pixel should have fired") - - DuckPlayerExperimentPixelFireMock.tearDown() - - // Change the date to something in the future - dateProvider.setCurrentDate(Date(timeIntervalSince1970: 1726185600)) - sut.assignUserToCohort() - XCTAssertEqual(DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory.count, 0, "Enrollment pixel should not have fired again") - XCTAssertEqual(sut.isEnrolled, true, "The assigned date should not change.") - XCTAssertEqual(DuckPlayerLaunchExperiment.formattedDate(sut.enrollmentDateV2 ?? Date()), "20240910", "The assigned date should not change.") - } - - func testIfUserIsEnrolled_SearchDailyPixelsFire() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - sut.assignUserToCohort() - - for day in 0...14 { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day, but only one should be registered - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let dailyPixel = history.filter { $0.pixel == .duckplayerExperimentDailySearch } - - // Assign cohort - XCTAssertEqual(dailyPixel.count, 15, "There must be 15 daily pixels") - - for (index, value) in dailyPixel.enumerated() { - XCTAssertEqual(value.params["day"], "\(index)") - XCTAssert(["control", "experiment"].contains(value.params["variant"]), "The variant is incorrect") - XCTAssertEqual(value.params["enrollment"], "20240910", "The assigned date is incorrect.") - } - - } - - func testIfUserIsEnrolled_SearchDailyPixelsFireWhenNotUsedDaily() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - // Assign cohort - sut.assignUserToCohort() - - let fireDays = [0, 4, 11, 12] - for day in fireDays { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day, but only one should be registered - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let dailyPixel = history.filter { $0.pixel == .duckplayerExperimentDailySearch } - - XCTAssertEqual(dailyPixel.count, 4, "There must be 4 daily pixels") - - if dailyPixel.count == 4 { - XCTAssertEqual(dailyPixel[0].params["day"], "0") - XCTAssertEqual(dailyPixel[1].params["day"], "4") - XCTAssertEqual(dailyPixel[2].params["day"], "11") - XCTAssertEqual(dailyPixel[3].params["day"], "12") - } - } - - func testIfUserIsEnrolled_WeeklyPixelsFire() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - // Assign cohort - sut.assignUserToCohort() - - for day in 0...13 { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day, but only one should be registered - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let weeklyPixel = history.filter { $0.pixel == .duckplayerExperimentWeeklySearch } - - XCTAssertEqual(weeklyPixel.count, 2, "There must be 2 weekly pixels") - - for (index, value) in weeklyPixel.enumerated() { - XCTAssertEqual(value.params["week"], "\(index+1)") - XCTAssert(["control", "experiment"].contains(value.params["variant"]), "The variant is incorrect") - XCTAssertEqual(value.params["enrollment"], "20240910", "The assigned date is incorrect.") - - } - - } - - func testIfUserIsEnrolled_WeeklyPixelsFireWhenNotUsedDaily() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - // Assign cohort - sut.assignUserToCohort() - - let fireDays = [0, 6, 11, 12] - for day in fireDays { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let weeklyPixel = history.filter { $0.pixel == .duckplayerExperimentWeeklySearch } - - XCTAssertEqual(weeklyPixel.count, 2, "There must be 2 weekly pixels") - - if weeklyPixel.count == 2 { - XCTAssertEqual(weeklyPixel[0].params["week"], "1") - XCTAssertEqual(weeklyPixel[1].params["week"], "2") - } - - } - - func testIfUserIsEnrolled_WeeklyPixelsFireWhenNotUsedWeek2() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - sut.assignUserToCohort() - - let fireDays = [0, 2, 3, 6] - for day in fireDays { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let weeklyPixel = history.filter { $0.pixel == .duckplayerExperimentWeeklySearch } - - // Assign cohort - XCTAssertEqual(weeklyPixel.count, 1, "There must be 2 weekly pixels") - - if weeklyPixel.count == 2 { - XCTAssertEqual(weeklyPixel[0].params["week"], "1") - } - - } - - func testIfUserIsEnrolled_WeeklyPixelsFireWhenNotUsedWeek1() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - // Assign cohort - sut.assignUserToCohort() - - let fireDays = [0, 8, 9, 12] - for day in fireDays { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let weeklyPixel = history.filter { $0.pixel == .duckplayerExperimentWeeklySearch } - - XCTAssertEqual(weeklyPixel.count, 1, "There must be 2 weekly pixels") - - if weeklyPixel.count == 2 { - XCTAssertEqual(weeklyPixel[0].params["week"], "2") - } - - } - - func testIfUserIsEnrolled_SearchPixelFires() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - // Assign cohort - sut.assignUserToCohort() - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - sut.fireSearchPixels() - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - - let searchPixel = history.filter { $0.pixel == .duckplayerExperimentSearch } - - if history.count == 1 { - XCTAssert(["control", "experiment"].contains(searchPixel.first?.params["variant"]), "The variant is incorrect") - XCTAssertEqual(searchPixel.first?.params["enrollment"], "20240910", "The assigned date should be valid.") - } - - } - - func testIfUserIsEnrolled_YoutubePixelFires() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(duckPlayerMode: .alwaysAsk, - referrer: .serp, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - sut.assignUserToCohort() - - XCTAssertTrue(sut.isEnrolled, "User should be enrolled after assigning to cohort.") - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (3 * 86400))) // Day 3 - sut.fireYoutubePixel(videoID: "testVideoID") - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let youtubePixel = history.filter { $0.pixel == .duckplayerExperimentYoutubePageView } - - // Validate that one YouTube pixel was fired - XCTAssertEqual(youtubePixel.count, 1, "There should be exactly one YouTube pixel fired.") - - if let firedPixel = youtubePixel.first { - XCTAssertEqual(firedPixel.params["day"], "3", "The day parameter is incorrect.") - XCTAssert(["control", "experiment"].contains(firedPixel.params["variant"]), "The variant is incorrect.") - XCTAssertEqual(firedPixel.params["enrollment"], "20240910", "The enrollment date should be valid.") - XCTAssertEqual(firedPixel.params["state"], "alwaysAsk", "The state parameter is incorrect.") - XCTAssertEqual(firedPixel.params["referrer"], "serp", "The referrer parameter is incorrect.") - } - - } - -} diff --git a/DuckDuckGoTests/DuckPlayerMocks.swift b/DuckDuckGoTests/DuckPlayerMocks.swift index cf387e5578..5f0cba2923 100644 --- a/DuckDuckGoTests/DuckPlayerMocks.swift +++ b/DuckDuckGoTests/DuckPlayerMocks.swift @@ -66,6 +66,7 @@ class MockWebView: WKWebView { override func load(_ request: URLRequest) -> WKNavigation? { lastLoadedRequest = request + currentURL = request.url return nil } diff --git a/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift b/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift index 3b50f07aef..c9b7952687 100644 --- a/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift +++ b/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift @@ -23,15 +23,6 @@ import ContentScopeScripts import Combine import BrowserServicesKit -class DuckPlayerExperimentMock: DuckPlayerLaunchExperimentHandling { - var duckPlayerMode: DuckDuckGo.DuckPlayerMode? - var isEnrolled = true - var isExperimentCohort = true - func assignUserToCohort() {} - func fireSearchPixels() {} - func fireYoutubePixel(videoID: String) {} -} - @testable import DuckDuckGo class DuckPlayerNavigationHandlerTests: XCTestCase { @@ -107,226 +98,299 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { } - // MARK: - Decide policyFor Tests - + // MARK: Handle Navigation Tests @MainActor - func testDecidePolicyForVideoWasAlreadyHandled() { + func testAgeRestrictedVideoShouldNotBeHandled() { - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s&embeds_referring_euri=somevalue")! let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + playerSettings.mode = .enabled let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - var navigationPolicy: WKNavigationActionPolicy? - - handler.lastHandledVideoID = "abc123" + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy - expectation.fulfill() - }, webView: mockWebView) - - waitForExpectations(timeout: 1, handler: nil) + handler.handleNavigation(navigationAction, webView: webView) + XCTAssertEqual(webView.url, youtubeURL) - XCTAssertEqual(navigationPolicy, .cancel, "Expected navigation policy to be .cancel") - } @MainActor - func testDecidePolicyForVideosThatShouldLoadInYoutube() { + func testHandleNavigationLoadsDuckPlayerWhenEnabled() { - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s&embeds_referring_euri=somevalue")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") + let link = URL(string: "duck://player/12345")! + let navigationAction = MockNavigationAction(request: URLRequest(url: link)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + playerSettings.mode = .enabled let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - var navigationPolicy: WKNavigationActionPolicy? + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy + handler.handleNavigation(navigationAction, webView: webView) + + let expectation = self.expectation(description: "Simulated Request Expectation") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + + XCTAssertEqual(self.webView.url?.absoluteString, "https://www.youtube-nocookie.com/embed/12345") expectation.fulfill() - }, webView: mockWebView) - - waitForExpectations(timeout: 1, handler: nil) + } - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") - + waitForExpectations(timeout: 1.0, handler: nil) + } @MainActor - func testDecidePolicyForVideosThatShouldLoadInDuckPlayer() { + func testHandleNavigationLoadsDuckPlayerWhenAskMode() { - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") + let link = URL(string: "duck://player/12345")! + let navigationAction = MockNavigationAction(request: URLRequest(url: link)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.mode = .enabled + playerSettings.mode = .alwaysAsk let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - var navigationPolicy: WKNavigationActionPolicy? + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy + handler.handleNavigation(navigationAction, webView: webView) + + let expectation = self.expectation(description: "Simulated Request Expectation") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + + XCTAssertEqual(self.webView.url?.absoluteString, "https://www.youtube-nocookie.com/embed/12345") expectation.fulfill() - }, webView: mockWebView) - - waitForExpectations(timeout: 1, handler: nil) + } - XCTAssertEqual(navigationPolicy, .cancel, "Expected navigation policy to be .cancel") - + waitForExpectations(timeout: 1.0, handler: nil) + } @MainActor - func testDecidePolicyForOtherURLThatShouldLoadNormally() { + func testHandleNavigationWithDuckPlayerDisabledRedirectsToYoutube() { - let youtubeURL = URL(string: "https://www.google.com/")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") + let link = URL(string: "duck://player/12345")! + let navigationAction = MockNavigationAction(request: URLRequest(url: link)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.mode = .enabled + playerSettings.mode = .disabled let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - var navigationPolicy: WKNavigationActionPolicy? + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy + handler.handleNavigation(navigationAction, webView: webView) + + let expectation = self.expectation(description: "Youtube URL request") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + + guard let redirectedURL = self.webView.url, + let components = URLComponents(url: redirectedURL, resolvingAgainstBaseURL: false) else { + XCTFail("URL is missing or could not be parsed.") + expectation.fulfill() + return + } + + // Extract path and video ID from the redirected URL + let isWatchPath = components.path == "/watch" + let videoID = components.queryItems?.first(where: { $0.name == "v" })?.value + + XCTAssertTrue(isWatchPath, "Expected the path to be /watch.") + XCTAssertEqual(videoID, "12345", "Expected the video ID to match.") expectation.fulfill() - }, webView: mockWebView) - - waitForExpectations(timeout: 1, handler: nil) + } - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") - + waitForExpectations(timeout: 1.0, handler: nil) } - // MARK: - HandleJS Navigation Tests - @MainActor - func testJSNavigationForVideoWasAlreadyHandled() { - - let url: URL = URL(string: "https://www.example.com/")! - webView.load(URLRequest(url: url)) - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! - + func testHandleNavigationLoadsOpenInYoutubeURL() { + + let link = URL(string: "duck://player/openInYoutube?v=12345")! + let navigationAction = MockNavigationAction(request: URLRequest(url: link)) let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + playerSettings.mode = .alwaysAsk let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) - handler.lastHandledVideoID = "abc123" - handler.handleJSNavigation(url: youtubeURL, webView: webView) + handler.handleNavigation(navigationAction, webView: webView) + + let expectation = self.expectation(description: "Youtube Redirect Expectation") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + + XCTAssertEqual(self.webView.url?.absoluteString, "https://m.youtube.com/watch?v=12345&embeds_referring_euri=some_value") + expectation.fulfill() + } - XCTAssertEqual(webView.url?.absoluteString, url.absoluteString) + waitForExpectations(timeout: 1.0, handler: nil) + } + + // MARK: Handle URL Change tests @MainActor - func testJSNavigationForVideoThatShouldLoadInYoutube() { + func testReturnsNotHandledWhenAlreadyDuckAddress() { + let url = URL(string: "duck://player/12345")! - let url: URL = URL(string: "https://www.example.com/")! - webView.load(URLRequest(url: url)) - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s&embeds_referring_euri=somevalue")! - + // Set up mock player settings and player let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + playerSettings.mode = .disabled let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - - handler.handleJSNavigation(url: youtubeURL, webView: webView) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) - XCTAssertEqual(webView.url?.absoluteString, url.absoluteString) + // Simulate webView loading the URL + _ = mockWebView.load(URLRequest(url: url)) + + let result = handler.handleURLChange(webView: mockWebView) + + switch result { + case .notHandled(let reason): + XCTAssertEqual(reason, .isAlreadyDuckAddress, "Expected .isAlreadyDuckAddress, but got \(reason).") + default: + XCTFail("Expected .notHandled(.isAlreadyDuckAddress), but got \(result).") + } } @MainActor - func testJSNavigationForVideoThatShouldLoadInDuckPlayer() { + func testReturnsNotHandledWhenURLNotChanged() { + let url = URL(string: "https://duckduckgo.com/?t=h_&q=search&ia=web")! - let url: URL = URL(string: "https://www.example.com/")! - webView.load(URLRequest(url: url)) - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! + // Set up mock player settings and player let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.mode = .enabled let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) + + // Load the first URL + _ = mockWebView.load(URLRequest(url: url)) - handler.handleJSNavigation(url: youtubeURL, webView: webView) + let result = handler.handleURLChange(webView: mockWebView) + + // Try to handle the same URL + let result2 = handler.handleURLChange(webView: mockWebView) + + switch result2 { + case .notHandled(let reason): + XCTAssertEqual(reason, .urlHasNotChanged, "Expected .urlHasNotChanged, but got \(reason).") + default: + XCTFail("Expected .notHandled(.urlHasNotChanged), but got \(result).") + } + } + + @MainActor + func testReturnsNotHandledWhenDuckPlayerDisabled() { + let url = URL(string: "https://www.youtube.com/watch?v=I9J120SZT14")! + + // Set up mock player settings and player + let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + playerSettings.mode = .disabled + let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) - XCTAssertEqual(webView.url?.absoluteString, "duck://player/abc123?t=10s") + // Simulate webView loading the URL + _ = mockWebView.load(URLRequest(url: url)) + + let result = handler.handleURLChange(webView: mockWebView) + + switch result { + case .notHandled(let reason): + XCTAssertEqual(reason, .duckPlayerDisabled, "Expected .duckPlayerDisabled, but got \(reason).") + default: + XCTFail("Expected .notHandled(.duckPlayerDisabled), but got \(result).") + } } - // MARK: Handle Navigation Tests - @MainActor - func testAgeRestrictedVideoShouldNotBeHandled() { + func testReturnsNotHandledWhenNoVideoDetailsPresent() { + let url = URL(string: "https://www.vimeo.com/video=I9J120SZT14")! - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s&embeds_referring_euri=somevalue")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) + // Set up mock player settings and player let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.mode = .enabled let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - - handler.handleNavigation(navigationAction, webView: webView) - XCTAssertEqual(webView.url, nil) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) + // Simulate webView loading the URL + _ = mockWebView.load(URLRequest(url: url)) + + let result = handler.handleURLChange(webView: mockWebView) + + switch result { + case .notHandled(let reason): + XCTAssertEqual(reason, .videoIDNotPresent, "Expected .videoIDNotPresent, but got \(reason).") + default: + XCTFail("Expected .notHandled(.videoIDNotPresent), but got \(result).") + } + } + + @MainActor + func testReturnsNotHandledWhenVideoAlreadyRendered() { + // Set up mock player settings and player + let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + playerSettings.mode = .enabled + let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) + + // Simulate webView loading the URL + let url1 = URL(string: "https://www.youtube.com/watch?v=I9J120SZT14")! + _ = mockWebView.load(URLRequest(url: url1)) + let result1 = handler.handleURLChange(webView: mockWebView) + + // Load the Same video but slightly different URL (Redirecting to the m subdomain is quite common) + let url2 = URL(string: "https://m.youtube.com/watch?v=I9J120SZT14")! + _ = mockWebView.load(URLRequest(url: url2)) + let result2 = handler.handleURLChange(webView: mockWebView) + + switch result2 { + case .notHandled(let reason): + XCTAssertEqual(reason, .videoAlreadyHandled, "Expected .videoAlreadyHandled, but got \(reason).") + default: + XCTFail("Expected .notHandled(.videoAlreadyHandled), but got \(result2).") + } } @MainActor - func testHandleNavigationLoadsDuckPlayer() { + func testReturnsNotHandledWhenShouldBeDisabledForNextVideo() { + let url = URL(string: "https://www.youtube.com/watch?v=I9J120SZT14")! - let link = URL(string: "duck://player/12345")! - let navigationAction = MockNavigationAction(request: URLRequest(url: link)) + // Set up mock player settings and player let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.mode = .enabled + playerSettings.allowFirstVideo = true let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) - handler.handleNavigation(navigationAction, webView: webView) + // Simulate webView loading the URL + _ = mockWebView.load(URLRequest(url: url)) - let expectation = self.expectation(description: "Simulated Request Expectation") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - - XCTAssertEqual(self.webView.url?.absoluteString, "https://www.youtube-nocookie.com/embed/12345") - expectation.fulfill() + let result = handler.handleURLChange(webView: mockWebView) + + switch result { + case .notHandled(let reason): + XCTAssertEqual(reason, .disabledForNextVideo, "Expected .disabledForNextVideo, but got \(reason).") + default: + XCTFail("Expected .notHandled(.disabledForNextVideo), but got \(result).") } - - waitForExpectations(timeout: 1.0, handler: nil) - } @MainActor - func testHandleNavigationWithDuckPlayerDisabledRedirectsToYoutube() { + func testReturnsNotHandledForYoutubePlayerLinks() { + let url = URL(string: "https://www.youtube.com/watch?v=I9J120SZT14&&embeds_referring_euri=somevalue")! - let link = URL(string: "duck://player/12345")! - let navigationAction = MockNavigationAction(request: URLRequest(url: link)) + // Set up mock player settings and player let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.mode = .disabled + playerSettings.mode = .enabled + playerSettings.allowFirstVideo = true let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) - handler.handleNavigation(navigationAction, webView: webView) + // Simulate webView loading the URL + _ = mockWebView.load(URLRequest(url: url)) - let expectation = self.expectation(description: "Youtube URL request") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - - guard let redirectedURL = self.webView.url, - let components = URLComponents(url: redirectedURL, resolvingAgainstBaseURL: false) else { - XCTFail("URL is missing or could not be parsed.") - expectation.fulfill() - return - } - - // Extract path and video ID from the redirected URL - let isWatchPath = components.path == "/watch" - let videoID = components.queryItems?.first(where: { $0.name == "v" })?.value - - XCTAssertTrue(isWatchPath, "Expected the path to be /watch.") - XCTAssertEqual(videoID, "12345", "Expected the video ID to match.") - expectation.fulfill() + let result = handler.handleURLChange(webView: mockWebView) + + switch result { + case .notHandled(let reason): + XCTAssertEqual(reason, .disabledForNextVideo, "Expected .disabledForNextVideo, but got \(reason).") + default: + XCTFail("Expected .notHandled(.disabledForNextVideo), but got \(result).") } - - waitForExpectations(timeout: 1.0, handler: nil) } + + // MARK: Navigational Actions @MainActor func testHandleReloadForDuckPlayerVideo() { let duckPlayerURL = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! @@ -334,7 +398,7 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.mode = .enabled let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) handler.handleReload(webView: mockWebView) if let loadedRequest = mockWebView.lastLoadedRequest { @@ -352,7 +416,7 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.mode = .enabled let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) handler.handleAttach(webView: mockWebView) if let loadedRequest = mockWebView.lastLoadedRequest { @@ -368,7 +432,7 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { playerSettings.mode = .enabled let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) var url = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! var duckURL = handler.getDuckURLFor(url).absoluteString XCTAssertEqual(duckURL, "duck://player/abc123?t=10s") @@ -384,7 +448,7 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { playerSettings.mode = .alwaysAsk let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) var url = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! var duckURL = handler.getDuckURLFor(url).absoluteString XCTAssertEqual(duckURL, "duck://player/abc123?t=10s") @@ -400,7 +464,7 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { playerSettings.mode = .disabled let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) var url = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! var duckURL = handler.getDuckURLFor(url).absoluteString XCTAssertEqual(duckURL, url.absoluteString) @@ -419,7 +483,7 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) handler.navigationType = .linkActivated playerSettings.mode = .enabled @@ -435,7 +499,7 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) handler.navigationType = .linkActivated playerSettings.mode = .enabled @@ -451,7 +515,7 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) handler.navigationType = .linkActivated playerSettings.mode = .enabled @@ -459,12 +523,13 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { XCTAssertFalse(handler.shouldOpenInNewTab(navigationAction, webView: webView)) } + func testHandleJSNavigationEventWhenEnabled() { let youtubeURL = URL(string: "duck://player/abc123")! let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) playerSettings.mode = .enabled mockAppSettings.duckPlayerOpenInNewTab = true @@ -479,7 +544,7 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) playerSettings.mode = .enabled mockAppSettings.duckPlayerOpenInNewTab = false @@ -494,7 +559,7 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings) handler.navigationType = .linkActivated playerSettings.mode = .disabled @@ -504,5 +569,6 @@ class DuckPlayerNavigationHandlerTests: XCTestCase { XCTAssertFalse(handler.navigationType == .linkActivated) } + }