Skip to content

Commit

Permalink
Update Overlay Pixel implementation (#3701)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1204099484721401/1208910025199824/f
Tech Design URL:
CC:

**Description**:
Refactors Overlay Pixels to use WebKit KVO and backForward lists for
improved reliability
  • Loading branch information
afterxleep authored Dec 9, 2024
1 parent 8c0cb45 commit 41b434c
Show file tree
Hide file tree
Showing 5 changed files with 117 additions and 220 deletions.
4 changes: 0 additions & 4 deletions DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1045,7 +1045,6 @@
D6ACEA322BBD55BF008FADDF /* TabURLInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACEA312BBD55BF008FADDF /* TabURLInterceptor.swift */; };
D6B67A122C332B6E002122EB /* DuckPlayerMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B67A112C332B6E002122EB /* DuckPlayerMocks.swift */; };
D6B9E8D22CDA4420002B640C /* DuckPlayerOverlayUsagePixels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9E8D12CDA4418002B640C /* DuckPlayerOverlayUsagePixels.swift */; };
D6B9E8D42CDA8375002B640C /* DuckPlayerOverlayUsagePixelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9E8D32CDA8369002B640C /* DuckPlayerOverlayUsagePixelsTests.swift */; };
D6BC8ACB2C5AA3860025375B /* DuckPlayer in Frameworks */ = {isa = PBXBuildFile; productRef = D6BC8ACA2C5AA3860025375B /* DuckPlayer */; };
D6BFCB5F2B7524AA0051FF81 /* SubscriptionPIRView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */; };
D6BFCB612B7525160051FF81 /* SubscriptionPIRViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BFCB602B7525160051FF81 /* SubscriptionPIRViewModel.swift */; };
Expand Down Expand Up @@ -2894,7 +2893,6 @@
D6ACEA312BBD55BF008FADDF /* TabURLInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabURLInterceptor.swift; sourceTree = "<group>"; };
D6B67A112C332B6E002122EB /* DuckPlayerMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerMocks.swift; sourceTree = "<group>"; };
D6B9E8D12CDA4418002B640C /* DuckPlayerOverlayUsagePixels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerOverlayUsagePixels.swift; sourceTree = "<group>"; };
D6B9E8D32CDA8369002B640C /* DuckPlayerOverlayUsagePixelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerOverlayUsagePixelsTests.swift; sourceTree = "<group>"; };
D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPIRView.swift; sourceTree = "<group>"; };
D6BFCB602B7525160051FF81 /* SubscriptionPIRViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPIRViewModel.swift; sourceTree = "<group>"; };
D6D95CE22B6D9F8800960317 /* AsyncHeadlessWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncHeadlessWebViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -5535,7 +5533,6 @@
D62EC3B72C24695800FC9D04 /* DuckPlayer */ = {
isa = PBXGroup;
children = (
D6B9E8D32CDA8369002B640C /* DuckPlayerOverlayUsagePixelsTests.swift */,
D6B67A112C332B6E002122EB /* DuckPlayerMocks.swift */,
D62EC3BB2C2470E000FC9D04 /* DuckPlayerTests.swift */,
D62EC3B82C246A5600FC9D04 /* YoutublePlayerNavigationHandlerTests.swift */,
Expand Down Expand Up @@ -8272,7 +8269,6 @@
5694372B2BE3F2D900C0881B /* SyncErrorHandlerTests.swift in Sources */,
4B27FBB52C927435007E21A7 /* PersistentPixelTests.swift in Sources */,
987130C7294AAB9F00AB05E0 /* MenuBookmarksViewModelTests.swift in Sources */,
D6B9E8D42CDA8375002B640C /* DuckPlayerOverlayUsagePixelsTests.swift in Sources */,
858650D32469BFAD00C36F8A /* DaxDialogTests.swift in Sources */,
31C138B227A4097800FFD4B2 /* DownloadTestsHelper.swift in Sources */,
1E1D8B5D2994FFE100C96994 /* AutoconsentMessageProtocolTests.swift in Sources */,
Expand Down
45 changes: 28 additions & 17 deletions DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import Common
import BrowserServicesKit
import DuckPlayer
import os.log
import Combine

/// Handles navigation and interactions related to Duck Player within the app.
final class DuckPlayerNavigationHandler: NSObject {
Expand Down Expand Up @@ -75,6 +76,9 @@ final class DuckPlayerNavigationHandler: NSObject {
/// Delegate for handling tab navigation events.
weak var tabNavigationHandler: DuckPlayerTabNavigationHandling?

/// Cancellable for observing DuckPlayer Mode changes
private var duckPlayerModeCancellable: AnyCancellable?

private struct Constants {
static let SERPURL = "duckduckgo.com/"
static let refererHeader = "Referer"
Expand Down Expand Up @@ -125,6 +129,8 @@ final class DuckPlayerNavigationHandler: NSObject {
self.dailyPixelFiring = dailyPixelFiring
self.tabNavigationHandler = tabNavigationHandler
self.duckPlayerOverlayUsagePixels = duckPlayerOverlayUsagePixels

super.init()
}

/// Returns the file path for the Duck Player HTML template.
Expand Down Expand Up @@ -552,6 +558,14 @@ final class DuckPlayerNavigationHandler: NSObject {
return false
}

/// Register a DuckPlayer mode Observe to handle events when the mode changes
private func setupPlayerModeObserver() {
duckPlayerModeCancellable = duckPlayer.settings.duckPlayerSettingsPublisher
.sink { [weak self] in
self?.duckPlayerOverlayUsagePixels?.duckPlayerMode = self?.duckPlayer.settings.mode ?? .disabled
}
}

/// // Handle "open in YouTube" links (duck://player/openInYoutube)
///
/// - Parameter url: The `URL` used to determine the tab type.
Expand All @@ -577,6 +591,11 @@ final class DuckPlayerNavigationHandler: NSObject {
}
}

deinit {
duckPlayerModeCancellable?.cancel()
duckPlayerModeCancellable = nil
}

}

extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling {
Expand Down Expand Up @@ -636,7 +655,6 @@ extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling {
// Before performing the simulated request
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
self.performRequest(request: newRequest, webView: webView)
self.duckPlayerOverlayUsagePixels?.handleNavigationAndFirePixels(url: url, duckPlayerMode: self.duckPlayerMode)
self.fireDuckPlayerPixels(webView: webView)

}
Expand Down Expand Up @@ -680,12 +698,6 @@ extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling {
return .notHandled(.duplicateNavigation)
}

// Overlay Usage Pixel handling
if let url = webView.url {
duckPlayerOverlayUsagePixels?.handleNavigationAndFirePixels(url: url, duckPlayerMode: duckPlayerMode)
lastURLChangeHandling = Date()
}

// Check if DuckPlayer feature is enabled
guard isDuckPlayerFeatureEnabled else {
return .notHandled(.featureOff)
Expand Down Expand Up @@ -786,6 +798,9 @@ extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling {
guard let url = webView.url else {
return
}

// Fire Reload Pixel
duckPlayerOverlayUsagePixels?.fireReloadPixelIfNeeded(url: url)

if url.isDuckPlayer, duckPlayerMode != .disabled {
redirectToDuckPlayerVideo(url: url, webView: webView, disableNewTab: true)
Expand All @@ -809,6 +824,11 @@ extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling {

// Reset referrer and initial settings
referrer = .other

// Attach WebView to OverlayPixels
duckPlayerOverlayUsagePixels?.webView = webView
duckPlayerOverlayUsagePixels?.duckPlayerMode = duckPlayer.settings.mode
setupPlayerModeObserver()

// Ensure feature and mode are enabled
guard isDuckPlayerFeatureEnabled,
Expand All @@ -825,6 +845,7 @@ extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling {
referrer = parameters.referrer
redirectToDuckPlayerVideo(url: url, webView: webView, disableNewTab: true)
}

}

/// Updates the referrer after the web view finishes loading a page.
Expand All @@ -836,16 +857,6 @@ extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling {
// Reset allowFirstVideo
duckPlayer.settings.allowFirstVideo = false

// Overlay Usage Pixel handling for Direct Navigation
if let url = webView.url, !url.isYoutube {
duckPlayerOverlayUsagePixels?.handleNavigationAndFirePixels(url: url, duckPlayerMode: duckPlayerMode)
}
// Reset Overlay Last Fired pixel after the page is loaded
// A delay is required as Youtube sometimes performs an extra redirect on load
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.duckPlayerOverlayUsagePixels?.lastFiredPixel = nil
}


}

Expand Down
145 changes: 82 additions & 63 deletions DuckDuckGo/DuckPlayer/DuckPlayerOverlayUsagePixels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,91 +17,110 @@
// limitations under the License.
//

import WebKit
import Core

protocol DuckPlayerOverlayPixelFiring {

var pixelFiring: PixelFiring.Type { get set }
var navigationHistory: [URL] { get set }
var lastFiredPixel: Pixel.Event? { get set }

func handleNavigationAndFirePixels(url: URL?, duckPlayerMode: DuckPlayerMode)
var webView: WKWebView? { get set }
var duckPlayerMode: DuckPlayerMode { get set }
func fireNavigationPixelsIfNeeded(webView: WKWebView)
func fireReloadPixelIfNeeded(url: URL)
}

final class DuckPlayerOverlayUsagePixels: DuckPlayerOverlayPixelFiring {
final class DuckPlayerOverlayUsagePixels: NSObject, DuckPlayerOverlayPixelFiring {

var pixelFiring: PixelFiring.Type
var navigationHistory: [URL] = []
var lastFiredPixel: Pixel.Event?
var duckPlayerMode: DuckPlayerMode = .disabled
private var isObserving = false

weak var webView: WKWebView? {
didSet {
if let webView {
addObservers(to: webView)
}
}
}

private var idleTimer: Timer?
private var idleTimeInterval: TimeInterval
private var lastVisitedURL: URL? // Tracks the last known URL

init(pixelFiring: PixelFiring.Type = Pixel.self,
navigationHistory: [URL] = [],
timeoutInterval: TimeInterval = 30.0) {
init(pixelFiring: PixelFiring.Type = Pixel.self) {
self.pixelFiring = pixelFiring
self.idleTimeInterval = timeoutInterval
}

func handleNavigationAndFirePixels(url: URL?, duckPlayerMode: DuckPlayerMode) {
guard let url = url else { return }
let comparisonURL = url.forComparison()

// Only append the URL if it's different from the last entry in normalized form
navigationHistory.append(comparisonURL)

// DuckPlayer is in Ask Mode, there's navigation history, and last URL is a YouTube Watch Video
guard duckPlayerMode == .alwaysAsk,
navigationHistory.count > 1,
let currentURL = navigationHistory.last,
let previousURL = navigationHistory.dropLast().last,
previousURL.isYoutubeWatch else { return }

var isReload = false
// Check for a reload condition: when current videoID is the same as Previous
if let currentVideoID = currentURL.youtubeVideoParams?.videoID,
let previousVideoID = previousURL.youtubeVideoParams?.videoID,
!previousURL.isDuckPlayer, !currentURL.isDuckPlayer {
isReload = currentVideoID == previousVideoID
deinit {
if let webView {
removeObservers(from: webView)
}
}

func fireNavigationPixelsIfNeeded(webView: WKWebView) {

// Fire the reload pixel if this is a reload navigation
if isReload {
firePixel(.duckPlayerYouTubeOverlayNavigationRefresh)
} else {
// Determine if it’s a back navigation by looking further back in history
let isBackNavigation = navigationHistory.count > 2 &&
navigationHistory[navigationHistory.count - 3].forComparison() == currentURL.forComparison()

// Fire the appropriate pixel based on navigation type
if isBackNavigation {
firePixel(.duckPlayerYouTubeOverlayNavigationBack)
} else if previousURL.isYoutubeWatch && currentURL.isYoutube {
// Forward navigation within YouTube (including non-video URLs)
firePixel(.duckPlayerYouTubeNavigationWithinYouTube)
} else if previousURL.isYoutubeWatch && !currentURL.isYoutube && !currentURL.isDuckPlayer {
// Navigation outside YouTube
firePixel(.duckPlayerYouTubeOverlayNavigationOutsideYoutube)
navigationHistory.removeAll()
}
guard let currentURL = webView.url else {
return
}

let backItemURL = webView.backForwardList.backItem?.url

// Truncation logic: Remove all URLs up to the last occurrence of the current URL in normalized form
if navigationHistory.count > 0 {
if let lastOccurrenceIndex = (0..<navigationHistory.count - 1).last(where: { navigationHistory[$0].forComparison() == comparisonURL }) {
navigationHistory = Array(navigationHistory.prefix(upTo: lastOccurrenceIndex + 1))

if let (currentVideoID, _) = currentURL.youtubeVideoParams,
let (oldVideoID, _) = lastVisitedURL?.youtubeVideoParams,
oldVideoID == currentVideoID {
return
}

if lastVisitedURL != nil {
// Back navigation
if currentURL == backItemURL {
firePixelIfNeeded(Pixel.Event.duckPlayerYouTubeOverlayNavigationBack, url: lastVisitedURL)
}
// Regular navigation
else {
if currentURL.isYoutube {
firePixelIfNeeded(Pixel.Event.duckPlayerYouTubeNavigationWithinYouTube, url: lastVisitedURL)
} else {
firePixelIfNeeded(Pixel.Event.duckPlayerYouTubeOverlayNavigationOutsideYoutube, url: lastVisitedURL)
}
}
}

// Update the last visited URL
lastVisitedURL = currentURL
}

private func firePixel(_ pixel: Pixel.Event) {
if lastFiredPixel == .duckPlayerYouTubeOverlayNavigationRefresh && pixel == .duckPlayerYouTubeOverlayNavigationRefresh {
return

func fireReloadPixelIfNeeded(url: URL) {
firePixelIfNeeded(Pixel.Event.duckPlayerYouTubeOverlayNavigationRefresh, url: lastVisitedURL)
}

// MARK: - Observer Management

private func addObservers(to webView: WKWebView) {
removeObservers(from: webView)
webView.addObserver(self, forKeyPath: #keyPath(WKWebView.url), options: [.new, .old], context: nil)
isObserving = true
}

private func removeObservers(from webView: WKWebView) {
if isObserving {
webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.url))
isObserving = false
}
lastFiredPixel = pixel
pixelFiring.fire(pixel, withAdditionalParameters: [:])
}

// swiftlint:disable block_based_kvo
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
guard let webView = object as? WKWebView else { return }

if keyPath == #keyPath(WKWebView.url) {
fireNavigationPixelsIfNeeded(webView: webView)
}
}
// swiftlint:enable block_based_kvo

private func firePixelIfNeeded(_ pixel: Pixel.Event, url: URL?) {
if let url, url.isYoutubeWatch, duckPlayerMode == .alwaysAsk {
pixelFiring.fire(pixel, withAdditionalParameters: [:])
}
}
}
13 changes: 7 additions & 6 deletions DuckDuckGo/TabViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,12 @@ class TabViewController: UIViewController {
}

instrumentation.didPrepareWebView()

// Initialize DuckPlayerNavigationHandler
if let handler = duckPlayerNavigationHandler,
let webView = webView {
handler.handleAttach(webView: webView)
}

if consumeCookies {
consumeCookiesThenLoadRequest(request)
Expand All @@ -623,12 +629,7 @@ class TabViewController: UIViewController {
// break a js-initiated popup request such as printing from a popup
guard self?.url != cleanURLRequest.url || loadingStopped || !loadingInitiatedByParentTab else { return }
self?.load(urlRequest: cleanURLRequest)


if let handler = self?.duckPlayerNavigationHandler,
let webView = self?.webView {
handler.handleAttach(webView: webView)
}

})
}

Expand Down
Loading

0 comments on commit 41b434c

Please sign in to comment.