Skip to content

[ads] Search result attribution on iOS #21974

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ios/brave-ios/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ var braveTarget: PackageDescription.Target = .target(
.copy("Frontend/Reader/Reader.html"),
.copy("Frontend/Reader/ReaderViewLoading.html"),
.copy("Frontend/Browser/New Tab Page/Backgrounds/Assets/NTP_Images/corwin-prescott-3.jpg"),
.copy("Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/DomainSpecific/Paged/BraveSearchResultAdScript.js"),
.copy("Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/DomainSpecific/Paged/BraveSearchScript.js"),
.copy("Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/DomainSpecific/Paged/BraveSkusScript.js"),
.copy("Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/DomainSpecific/Paged/nacl.min.js"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,14 @@ extension BrowserViewController: WKNavigationDelegate {
// The tracker protection script
// This script will track what is blocked and increase stats
.trackerProtectionStats: requestURL.isWebPage(includeDataURIs: false) &&
domainForMainFrame.isShieldExpected(.AdblockAndTp, considerAllShieldsOption: true)
domainForMainFrame.isShieldExpected(.AdblockAndTp, considerAllShieldsOption: true),

// Add Brave search result ads processing script
// This script will process search result ads on the Brave search page.
.searchResultAd: BraveAds.shouldSupportSearchResultAds() &&
BraveSearchManager.isValidURL(requestURL) &&
!isPrivateBrowsing &&
!rewards.isEnabled
])
}

Expand All @@ -323,6 +330,15 @@ extension BrowserViewController: WKNavigationDelegate {
tab?.loadRequest(modifiedRequest)
return (.cancel, preferences)
}

if let braveSearchResultAdManager = tab?.braveSearchResultAdManager,
braveSearchResultAdManager.isSearchResultAdClickedURL(requestURL),
navigationAction.navigationType == .linkActivated {
braveSearchResultAdManager.maybeTriggerSearchResultAdClickedEvent(requestURL)
tab?.braveSearchResultAdManager = nil
} else {
tab?.braveSearchResultAdManager = BraveSearchResultAdManager(url: requestURL, rewards: rewards, isPrivateBrowsing: isPrivateBrowsing)
}

// We fetch cookies to determine if backup search was enabled on the website.
let profile = self.profile
Expand All @@ -347,6 +363,7 @@ extension BrowserViewController: WKNavigationDelegate {
}
}
} else {
tab?.braveSearchResultAdManager = nil
tab?.braveSearchManager = nil
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2554,7 +2554,8 @@ extension BrowserViewController: TabDelegate {
injectedScripts += [
LoginsScriptHandler(tab: tab, profile: profile, passwordAPI: braveCore.passwordAPI),
EthereumProviderScriptHandler(tab: tab),
SolanaProviderScriptHandler(tab: tab)
SolanaProviderScriptHandler(tab: tab),
BraveSearchResultAdScriptHandler(tab: tab)
]
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2024 The Brave Authors. All rights reserved.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

import BraveCore

// A helper class to handle Brave Search Result Ads.
class BraveSearchResultAdManager: NSObject {
private let searchResultAdClickedUrlPath = "/a/redirect"

private let placementId = "placement_id"

private let rewards: BraveRewards

private var searchResultAds = [String: BraveAds.SearchResultAdInfo]()

init?(url: URL, rewards: BraveRewards, isPrivateBrowsing: Bool) {
if !BraveAds.shouldSupportSearchResultAds() ||
!BraveSearchManager.isValidURL(url) ||
isPrivateBrowsing ||
rewards.isEnabled {
return nil
}

self.rewards = rewards
}

func isSearchResultAdClickedURL(_ url: URL) -> Bool {
return getPlacementID(url) != nil
}

func triggerSearchResultAdViewedEvent(
placementId: String,
searchResultAd: BraveAds.SearchResultAdInfo) {
searchResultAds[placementId] = searchResultAd

guard let searchResultAd = searchResultAds[placementId] else {
return
}

rewards.ads.triggerSearchResultAdEvent(
searchResultAd,
eventType: .viewed,
completion: { _ in })
}

func maybeTriggerSearchResultAdClickedEvent(_ url: URL) {
guard let placementId = getPlacementID(url) else {
return
}

guard let searchResultAd = searchResultAds[placementId] else {
return
}

rewards.ads.triggerSearchResultAdEvent(
searchResultAd,
eventType: .clicked,
completion: { _ in })
}

private func getPlacementID(_ url: URL) -> String? {
if !BraveSearchManager.isValidURL(url) ||
url.path != searchResultAdClickedUrlPath {
return nil
}
guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else {
return nil
}
return queryItems.first(where: { $0.name == placementId })?.value
}
}
3 changes: 3 additions & 0 deletions ios/brave-ios/Sources/Brave/Frontend/Browser/Tab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,9 @@ class Tab: NSObject {
/// A helper property that handles native to Brave Search communication.
var braveSearchManager: BraveSearchManager?

/// A helper property that handles Brave Search Result Ads.
var braveSearchResultAdManager: BraveSearchResultAdManager?

private lazy var refreshControl = UIRefreshControl().then {
$0.addTarget(self, action: #selector(reload), for: .valueChanged)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ class UserScriptManager {
case readyStateHelper
case ethereumProvider
case solanaProvider
case searchResultAd
case youtubeQuality

fileprivate var script: WKUserScript? {
Expand All @@ -118,7 +119,8 @@ class UserScriptManager {
case .trackerProtectionStats: return ContentBlockerHelper.userScript
case .ethereumProvider: return EthereumProviderScriptHandler.userScript
case .solanaProvider: return SolanaProviderScriptHandler.userScript

case .searchResultAd: return BraveSearchResultAdScriptHandler.userScript

// Always enabled scripts
case .faviconFetcher: return FaviconScriptHandler.userScript
case .rewardsReporting: return RewardsReportingScriptHandler.userScript
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright 2024 The Brave Authors. All rights reserved.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

import BraveCore
import BraveShared
import os.log
import WebKit

class BraveSearchResultAdScriptHandler: TabContentScript {
private struct SearchResultAdResponse: Decodable {
struct SearchResultAd: Decodable {
let creativeInstanceId: String
let placementId: String
let creativeSetId: String
let campaignId: String
let advertiserId: String
let landingPage: URL
let headlineText: String
let description: String
let rewardsValue: String
let conversionUrlPatternValue: String?
let conversionAdvertiserPublicKeyValue: String?
let conversionObservationWindowValue: Int?
}

let creatives: [SearchResultAd]
}

fileprivate weak var tab: Tab?

init(tab: Tab) {
self.tab = tab
}

static let scriptName = "BraveSearchResultAdScript"
static let scriptId = UUID().uuidString
static let messageHandlerName = "\(scriptName)_\(messageUUID)"
static let scriptSandbox: WKContentWorld = .defaultClient
static let userScript: WKUserScript? = {
guard var script = loadUserScript(named: scriptName) else {
return nil
}
return WKUserScript(source: secureScript(handlerName: messageHandlerName,
securityToken: scriptId,
script: script),
injectionTime: .atDocumentEnd,
forMainFrameOnly: true,
in: scriptSandbox)
}()

func userContentController(
_ userContentController: WKUserContentController,
didReceiveScriptMessage message: WKScriptMessage,
replyHandler: (Any?, String?) -> Void
) {
defer { replyHandler(nil, nil) }

if !verifyMessage(message: message) {
assertionFailure("Missing required security token.")
return
}

guard let tab = tab,
let braveSearchResultAdManager = tab.braveSearchResultAdManager
else {
Logger.module.error("Failed to get brave search result ad handler")
return
}

guard JSONSerialization.isValidJSONObject(message.body),
let messageData = try? JSONSerialization.data(withJSONObject: message.body, options: []),
let searchResultAds = try? JSONDecoder().decode(SearchResultAdResponse.self, from: messageData)
else {
Logger.module.error("Failed to parse search result ads response")
return
}

processSearchResultAds(searchResultAds, braveSearchResultAdManager: braveSearchResultAdManager)
}

private func processSearchResultAds(
_ searchResultAds: SearchResultAdResponse,
braveSearchResultAdManager: BraveSearchResultAdManager
) {
for ad in searchResultAds.creatives {
guard let rewardsValue = Double(ad.rewardsValue)
else {
Logger.module.error("Failed to process search result ads JSON-LD")
return
}

var conversion: BraveAds.ConversionInfo?
if let conversionUrlPatternValue = ad.conversionUrlPatternValue,
let conversionObservationWindowValue = ad.conversionObservationWindowValue {
let timeInterval = TimeInterval(conversionObservationWindowValue) * 1.days
conversion = .init(
urlPattern: conversionUrlPatternValue,
verifiableAdvertiserPublicKeyBase64: ad.conversionAdvertiserPublicKeyValue,
observationWindow: Date(timeIntervalSince1970: timeInterval)
)
}

let searchResultAd: BraveAds.SearchResultAdInfo = .init(
type: .searchResultAd,
placementId: ad.placementId,
creativeInstanceId: ad.creativeInstanceId,
creativeSetId: ad.creativeSetId,
campaignId: ad.campaignId,
advertiserId: ad.advertiserId,
targetUrl: ad.landingPage,
headlineText: ad.headlineText,
description: ad.description,
value: rewardsValue,
conversion: conversion
)

braveSearchResultAdManager.triggerSearchResultAdViewedEvent(
placementId: ad.placementId, searchResultAd:searchResultAd)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright (c) 2024 The Brave Authors. All rights reserved.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.

'use strict';

window.__firefox__.includeOnce('BraveSearchResultAdScript', function($) {
let sendMessage = $(function(creatives) {
$.postNativeMessage('$<message_handler>', {
"securityToken": SECURITY_TOKEN,
"creatives": creatives
});
});

let getJsonLdCreatives = () => {
const scripts =
document.querySelectorAll('script[type="application/ld+json"]');
const jsonLdList =
Array.from(scripts).map(script => JSON.parse(script.textContent));

if (!jsonLdList) {
return [];
}

const creativeFieldNamesMapping = {
'data-creative-instance-id': 'creativeInstanceId',
'data-placement-id': 'placementId',
'data-creative-set-id': 'creativeSetId',
'data-campaign-id': 'campaignId',
'data-advertiser-id': 'advertiserId',
'data-landing-page': 'landingPage',
'data-headline-text': 'headlineText',
'data-description': 'description',
'data-rewards-value': 'rewardsValue',
'data-conversion-url-pattern-value': 'conversionUrlPatternValue',
'data-conversion-advertiser-public-key-value':
'conversionAdvertiserPublicKeyValue',
'data-conversion-observation-window-value':
'conversionObservationWindowValue'
};

let jsonLdCreatives = [];
jsonLdList.forEach(jsonLd => {
if (jsonLd['@type'] === 'Product' && jsonLd.creatives) {
jsonLd.creatives.forEach(creative => {
if (creative['@type'] === 'SearchResultAd') {
let jsonLdCreative = {};
for (let key in creative) {
if (creativeFieldNamesMapping[key]) {
jsonLdCreative[creativeFieldNamesMapping[key]] = creative[key];
}
}
jsonLdCreatives.push(jsonLdCreative);
}
});
}
});

return jsonLdCreatives;
};

sendMessage(getJsonLdCreatives());
});
7 changes: 7 additions & 0 deletions ios/browser/api/ads/brave_ads.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ OBJC_EXPORT
/// disabled.
+ (BOOL)shouldAlwaysRunService;

/// Returns `true` if search result ads are supported.
+ (BOOL)shouldSupportSearchResultAds;

/// Whether or not Brave Ads is enabled and the user should receive
/// notification-style ads and be rewarded for it
@property(nonatomic, assign, getter=isEnabled)
Expand Down Expand Up @@ -119,6 +122,10 @@ OBJC_EXPORT
(BraveAdsPromotedContentAdEventType)eventType
completion:(void (^)(BOOL success))completion;

- (void)triggerSearchResultAdEvent:(BraveAdsSearchResultAdInfo*)searchResultAd
eventType:(BraveAdsSearchResultAdEventType)eventType
completion:(void (^)(BOOL success))completion;

- (void)purgeOrphanedAdEventsForType:(BraveAdsAdType)adType
completion:(void (^)(BOOL success))completion;

Expand Down
Loading