From 388b50ab2c6af7a80191a653a06cb13865d67ee1 Mon Sep 17 00:00:00 2001 From: Soner Yuksel Date: Wed, 28 Feb 2024 15:49:33 -0500 Subject: [PATCH] Fix #8521: ASA Custom Onboarding Feature Linkage (#8591) --- App/iOS/Delegates/AppDelegate.swift | 34 +-- App/iOS/Delegates/SceneDelegate.swift | 104 ++++----- ...er+BraveTalk.swift => BVC+BraveTalk.swift} | 0 ...roller+Callout.swift => BVC+Callout.swift} | 4 +- ....swift => BVC+DownloadQueueDelegate.swift} | 0 ...ate.swift => BVC+FindInPageDelegate.swift} | 0 ...+IPFSScheme.swift => BVC+IPFSScheme.swift} | 0 ...eyCommands.swift => BVC+KeyCommands.swift} | 0 ...ller+Keyboard.swift => BVC+Keyboard.swift} | 0 ...ewController+Menu.swift => BVC+Menu.swift} | 3 +- ...+Onboarding.swift => BVC+Onboarding.swift} | 2 +- ...+OpenSearch.swift => BVC+OpenSearch.swift} | 0 ...ViewController+P3A.swift => BVC+P3A.swift} | 0 .../BVC+Playlist.swift} | 0 ...on.swift => BVC+ProductNotification.swift} | 0 ...+ReaderMode.swift => BVC+ReaderMode.swift} | 0 ...ate.swift => BVC+TabManagerDelegate.swift} | 0 ...legate.swift => BVC+ToolbarDelegate.swift} | 0 ...ft => BVC+UIDropInteractionDelegate.swift} | 0 ...ate.swift => BVC+WKDownloadDelegate.swift} | 0 ...e.swift => BVC+WKNavigationDelegate.swift} | 0 ...ntroller+Wallet.swift => BVC+Wallet.swift} | 0 ...ervice.swift => BVC+Web3NameService.swift} | 0 ...roller+Widgets.swift => BVC+Widgets.swift} | 0 .../BrowserViewController.swift | 19 ++ .../OpenSearchEngineButton.swift | 0 .../Brave/Frontend/ClientPreferences.swift | 4 - ...onPreferencesDebugMenuViewController.swift | 8 +- .../Settings/SettingsViewController.swift | 7 +- .../Extensions/URLSessionExtensions.swift | 10 +- Sources/BraveStrings/BraveStrings.swift | 1 - Sources/Growth/DAU.swift | 2 +- ...ta.swift => AdAttributionReportData.swift} | 83 +++++-- Sources/Growth/URP/AttributionManager.swift | 203 ++++++++++++++++++ Sources/Growth/URP/UrpService.swift | 67 ++++-- Sources/Growth/URP/UserReferralProgram.swift | 57 +++-- .../Welcome/WelcomeViewCallout.swift | 23 +- .../Welcome/WelcomeViewController.swift | 93 +++++++- Sources/Preferences/GlobalPreferences.swift | 5 + 39 files changed, 566 insertions(+), 163 deletions(-) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+BraveTalk.swift => BVC+BraveTalk.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+Callout.swift => BVC+Callout.swift} (99%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+DownloadQueueDelegate.swift => BVC+DownloadQueueDelegate.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+FindInPageDelegate.swift => BVC+FindInPageDelegate.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+IPFSScheme.swift => BVC+IPFSScheme.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+KeyCommands.swift => BVC+KeyCommands.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+Keyboard.swift => BVC+Keyboard.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+Menu.swift => BVC+Menu.swift} (99%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+Onboarding.swift => BVC+Onboarding.swift} (99%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{OpenSearch/BrowserViewController+OpenSearch.swift => BVC+OpenSearch.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+P3A.swift => BVC+P3A.swift} (100%) rename Sources/Brave/Frontend/Browser/{Playlist/Browser/BrowserViewController+Playlist.swift => BrowserViewController/BVC+Playlist.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+ProductNotification.swift => BVC+ProductNotification.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+ReaderMode.swift => BVC+ReaderMode.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+TabManagerDelegate.swift => BVC+TabManagerDelegate.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+ToolbarDelegate.swift => BVC+ToolbarDelegate.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+UIDropInteractionDelegate.swift => BVC+UIDropInteractionDelegate.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+WKDownloadDelegate.swift => BVC+WKDownloadDelegate.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+WKNavigationDelegate.swift => BVC+WKNavigationDelegate.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+Wallet.swift => BVC+Wallet.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+Web3NameService.swift => BVC+Web3NameService.swift} (100%) rename Sources/Brave/Frontend/Browser/BrowserViewController/{BrowserViewController+Widgets.swift => BVC+Widgets.swift} (100%) rename Sources/Brave/Frontend/Browser/{ => BrowserViewController}/BrowserViewController.swift (99%) rename Sources/Brave/Frontend/Browser/{BrowserViewController/OpenSearch => Search}/OpenSearchEngineButton.swift (100%) rename Sources/Growth/URP/{AdAttributionData.swift => AdAttributionReportData.swift} (56%) create mode 100644 Sources/Growth/URP/AttributionManager.swift diff --git a/App/iOS/Delegates/AppDelegate.swift b/App/iOS/Delegates/AppDelegate.swift index cb29f55b52a..43569d3f48e 100644 --- a/App/iOS/Delegates/AppDelegate.swift +++ b/App/iOS/Delegates/AppDelegate.swift @@ -154,6 +154,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let isFirstLaunch = Preferences.General.isFirstLaunch.value Preferences.AppState.isOnboardingActive.value = isFirstLaunch + Preferences.AppState.dailyUserPingAwaitingUserConsent.value = isFirstLaunch if Preferences.Onboarding.basicOnboardingCompleted.value == OnboardingState.undetermined.rawValue { Preferences.Onboarding.basicOnboardingCompleted.value = @@ -205,31 +206,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Always load YouTube in Brave for new users Preferences.General.keepYouTubeInBrave.value = true } - - if UserReferralProgram.shared != nil { - if Preferences.URP.referralLookupOutstanding.value == nil { - // This preference has never been set, and this means it is a new or upgraded user. - // That distinction must be made to know if a network request for ref-code look up should be made. - - // Setting this to an explicit value so it will never get overwritten on subsequent launches. - // Upgrade users should not have ref code ping happening. - Preferences.URP.referralLookupOutstanding.value = isFirstLaunch - } - - SceneDelegate.shouldHandleUrpLookup = true - } else { - log.error("Failed to initialize user referral program") - DebugLogger.log(for: .urp, text: "Failed to initialize user referral program") - } - - if Preferences.URP.installAttributionLookupOutstanding.value == nil { - // Similarly to referral lookup, this prefrence should be set if it is a new user - // Trigger install attribution fetch only first launch - Preferences.URP.installAttributionLookupOutstanding.value = isFirstLaunch - - SceneDelegate.shouldHandleInstallAttributionFetch = true + + if Preferences.URP.referralLookupOutstanding.value == nil { + // This preference has never been set, and this means it is a new or upgraded user. + // That distinction must be made to know if a network request for ref-code look up should be made. + // Setting this to an explicit value so it will never get overwritten on subsequent launches. + // Upgrade users should not have ref code ping happening. + Preferences.URP.referralLookupOutstanding.value = isFirstLaunch } + SceneDelegate.shouldHandleUrpLookup = true + SceneDelegate.shouldHandleInstallAttributionFetch = true + #if canImport(BraveTalk) BraveTalkJitsiCoordinator.sendAppLifetimeEvent( .didFinishLaunching(options: launchOptions ?? [:]) diff --git a/App/iOS/Delegates/SceneDelegate.swift b/App/iOS/Delegates/SceneDelegate.swift index 1d5de04ac76..237f17d7765 100644 --- a/App/iOS/Delegates/SceneDelegate.swift +++ b/App/iOS/Delegates/SceneDelegate.swift @@ -40,10 +40,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } + let attributionManager = AttributionManager(dau: AppState.shared.dau, urp: UserReferralProgram.shared) + let browserViewController = createBrowserWindow( scene: windowScene, braveCore: AppState.shared.braveCore, profile: AppState.shared.profile, + attributionManager: attributionManager, diskImageStore: AppState.shared.diskImageStore, migration: AppState.shared.migration, rewards: AppState.shared.rewards, @@ -89,18 +92,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Handle URP Lookup at first launch if SceneDelegate.shouldHandleUrpLookup { SceneDelegate.shouldHandleUrpLookup = false - - if let urp = UserReferralProgram.shared { - browserViewController.handleReferralLookup(urp) - } - } - - // Handle Install Attribution Fetch at first launch - if SceneDelegate.shouldHandleInstallAttributionFetch { - SceneDelegate.shouldHandleInstallAttributionFetch = false - if let urp = UserReferralProgram.shared { - browserViewController.handleSearchAdsInstallAttribution(urp) + attributionManager.handleReferralLookup { [weak browserViewController] url in + browserViewController?.openReferralLink(url: url) } } @@ -118,6 +112,38 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { windowScene: windowScene, connectionOptions: connectionOptions ) + + // Handle Install Attribution Fetch at first launch + if SceneDelegate.shouldHandleInstallAttributionFetch { + SceneDelegate.shouldHandleInstallAttributionFetch = false + + // First time user should send dau ping after onboarding last stage _ p3a consent screen + // The reason p3a user consent is necesserray to call search ad install attribution API methods + if !Preferences.AppState.dailyUserPingAwaitingUserConsent.value { + // If P3A is not enabled, send the organic install code at daily pings which is BRV001 + // User has not opted in to share completely private and anonymous product insights + if AppState.shared.braveCore.p3aUtils.isP3AEnabled { + Task { @MainActor in + do { + try await attributionManager.handleSearchAdsInstallAttribution() + } catch { + Logger.module.debug("Error fetching ads attribution default code is sent \(error)") + // Sending default organic install code for dau + attributionManager.setupReferralCodeAndPingServer() + } + } + } else { + // Sending default organic install code for dau + attributionManager.setupReferralCodeAndPingServer() + } + } + } + + if Preferences.URP.installAttributionLookupOutstanding.value == nil { + // Similarly to referral lookup, this prefrence should be set if it is a new user + // Trigger install attribution fetch only first launch + Preferences.URP.installAttributionLookupOutstanding.value = Preferences.General.isFirstLaunch.value + } PrivacyReportsManager.scheduleNotification(debugMode: !AppConstants.buildChannel.isPublic) PrivacyReportsManager.consolidateData() @@ -212,7 +238,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // We try to send DAU ping each time the app goes to foreground to work around network edge cases // (offline, bad connection etc.). // Also send the ping only after the URP lookup and install attribution has processed. - if Preferences.URP.referralLookupOutstanding.value == false, Preferences.URP.installAttributionLookupOutstanding.value == false { + if Preferences.URP.referralLookupOutstanding.value == true, Preferences.URP.installAttributionLookupOutstanding.value == true { AppState.shared.dau.sendPingToServer() } @@ -422,6 +448,7 @@ extension SceneDelegate { private func createBrowserWindow(scene: UIWindowScene, braveCore: BraveCoreMain, profile: Profile, + attributionManager: AttributionManager, diskImageStore: DiskImageStore?, migration: Migration?, rewards: Brave.BraveRewards, @@ -480,6 +507,7 @@ extension SceneDelegate { let browserViewController = BrowserViewController( windowId: windowId, profile: profile, + attributionManager: attributionManager, diskImageStore: diskImageStore, braveCore: braveCore, rewards: rewards, @@ -574,58 +602,6 @@ extension SceneDelegate: UIViewControllerRestoration { } } -extension BrowserViewController { - func handleReferralLookup(_ urp: UserReferralProgram) { - if Preferences.URP.referralLookupOutstanding.value == true { - performProgramReferralLookup(urp, refCode: UserReferralProgram.getReferralCode()) - } else { - urp.pingIfEnoughTimePassed() - } - } - - func handleSearchAdsInstallAttribution(_ urp: UserReferralProgram) { - urp.adCampaignLookup() { [weak self] response, error in - guard let self = self else { return } - - let refCode = self.generateReferralCode(attributionData: response, fetchError: error) - // Setting up referral code value - // This value should be set before first DAU ping - Preferences.URP.referralCode.value = refCode - Preferences.URP.installAttributionLookupOutstanding.value = false - } - } - - private func generateReferralCode(attributionData: AdAttributionData?, fetchError: Error?) -> String { - // Prefix code "001" with BRV for organic iOS installs - var referralCode = "BRV001" - - if fetchError == nil, attributionData?.attribution == true, let campaignId = attributionData?.campaignId { - // Adding ASA User refcode prefix to indicate - // Apple Ads Attribution is true - referralCode = "ASA\(String(campaignId))" - } - - return referralCode - } - - private func performProgramReferralLookup(_ urp: UserReferralProgram, refCode: String?) { - urp.referralLookup(refCode: refCode) { referralCode, offerUrl in - // Attempting to send ping after first urp lookup. - // This way we can grab the referral code if it exists, see issue #2586. - if Preferences.URP.installAttributionLookupOutstanding.value == false { - AppState.shared.dau.sendPingToServer() - } - let retryTime = AppConstants.buildChannel.isPublic ? 1.days : 10.minutes - let retryDeadline = Date() + retryTime - - Preferences.NewTabPage.superReferrerThemeRetryDeadline.value = retryDeadline - - guard let url = offerUrl?.asURL else { return } - self.openReferralLink(url: url) - } - } -} - extension UIWindowScene { /// A single scene should only have ONE browserViewController /// However, it is possible that someone can create multiple, diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+BraveTalk.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+BraveTalk.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+BraveTalk.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+BraveTalk.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Callout.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Callout.swift similarity index 99% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Callout.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Callout.swift index 0099d21da3a..4530e6d7b3f 100644 --- a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Callout.swift +++ b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Callout.swift @@ -155,7 +155,9 @@ extension BrowserViewController { self?.dismiss(animated: false) } ) - ), p3aUtilities: braveCore.p3aUtils + ), + p3aUtilities: braveCore.p3aUtils, + attributionManager: attributionManager ) present(onboardingController, animated: true) diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+DownloadQueueDelegate.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+DownloadQueueDelegate.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+DownloadQueueDelegate.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+DownloadQueueDelegate.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+FindInPageDelegate.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+FindInPageDelegate.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+FindInPageDelegate.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+FindInPageDelegate.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+IPFSScheme.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+IPFSScheme.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+IPFSScheme.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+IPFSScheme.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+KeyCommands.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+KeyCommands.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+KeyCommands.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+KeyCommands.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Keyboard.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Keyboard.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Keyboard.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Keyboard.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Menu.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Menu.swift similarity index 99% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Menu.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Menu.swift index 73fc4630d9f..0c889177456 100644 --- a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Menu.swift +++ b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Menu.swift @@ -187,6 +187,7 @@ extension BrowserViewController { rewards: self.rewards, windowProtection: self.windowProtection, braveCore: self.braveCore, + attributionManager: attributionManager, keyringStore: keyringStore, cryptoStore: cryptoStore ) @@ -207,7 +208,7 @@ extension BrowserViewController { } } - private func presentPlaylistController() { + public func presentPlaylistController() { if PlaylistCarplayManager.shared.isPlaylistControllerPresented { let alert = UIAlertController(title: Strings.PlayList.playlistAlreadyShowingTitle, message: Strings.PlayList.playlistAlreadyShowingBody, diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Onboarding.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Onboarding.swift similarity index 99% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Onboarding.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Onboarding.swift index ff8c9939ead..7b0ffba8430 100644 --- a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Onboarding.swift +++ b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Onboarding.swift @@ -38,7 +38,7 @@ extension BrowserViewController { // 2. User hasn't completed onboarding if Preferences.Onboarding.basicOnboardingCompleted.value != OnboardingState.completed.rawValue, Preferences.Onboarding.isNewRetentionUser.value == true { - let onboardingController = WelcomeViewController(p3aUtilities: braveCore.p3aUtils) + let onboardingController = WelcomeViewController(p3aUtilities: braveCore.p3aUtils, attributionManager: attributionManager) onboardingController.modalPresentationStyle = .fullScreen parentController.present(onboardingController, animated: false) isOnboardingOrFullScreenCalloutPresented = true diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/OpenSearch/BrowserViewController+OpenSearch.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+OpenSearch.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/OpenSearch/BrowserViewController+OpenSearch.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+OpenSearch.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+P3A.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+P3A.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+P3A.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+P3A.swift diff --git a/Sources/Brave/Frontend/Browser/Playlist/Browser/BrowserViewController+Playlist.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Playlist.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/Playlist/Browser/BrowserViewController+Playlist.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Playlist.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+ProductNotification.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ProductNotification.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+ProductNotification.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ProductNotification.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+ReaderMode.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ReaderMode.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+ReaderMode.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ReaderMode.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+TabManagerDelegate.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+TabManagerDelegate.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+ToolbarDelegate.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+ToolbarDelegate.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+UIDropInteractionDelegate.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+UIDropInteractionDelegate.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+UIDropInteractionDelegate.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+UIDropInteractionDelegate.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+WKDownloadDelegate.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKDownloadDelegate.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+WKDownloadDelegate.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKDownloadDelegate.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+WKNavigationDelegate.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+WKNavigationDelegate.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Wallet.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Wallet.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Wallet.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Wallet.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Web3NameService.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Web3NameService.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Web3NameService.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Web3NameService.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Widgets.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Widgets.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Widgets.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Widgets.swift diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift similarity index 99% rename from Sources/Brave/Frontend/Browser/BrowserViewController.swift rename to Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift index d95d12741a1..5ce33b5965c 100644 --- a/Sources/Brave/Frontend/Browser/BrowserViewController.swift +++ b/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift @@ -138,6 +138,7 @@ public class BrowserViewController: UIViewController { private var privateModeCancellable: AnyCancellable? private var appReviewCancelable: AnyCancellable? + private var adFeatureLinkageCancelable: AnyCancellable? var onPendingRequestUpdatedCancellable: AnyCancellable? /// Voice Search @@ -163,6 +164,7 @@ public class BrowserViewController: UIViewController { public let windowId: UUID let profile: Profile + let attributionManager: AttributionManager let braveCore: BraveCoreMain let tabManager: TabManager let migration: Migration? @@ -273,6 +275,7 @@ public class BrowserViewController: UIViewController { public init( windowId: UUID, profile: Profile, + attributionManager: AttributionManager, diskImageStore: DiskImageStore?, braveCore: BraveCoreMain, rewards: BraveRewards, @@ -283,6 +286,7 @@ public class BrowserViewController: UIViewController { ) { self.windowId = windowId self.profile = profile + self.attributionManager = attributionManager self.braveCore = braveCore self.bookmarkManager = BookmarkManager(bookmarksAPI: braveCore.bookmarksAPI) self.rewards = rewards @@ -949,6 +953,21 @@ public class BrowserViewController: UIViewController { } }) + adFeatureLinkageCancelable = attributionManager + .$adFeatureLinkage + .removeDuplicates() + .sink(receiveValue: { [weak self] featureLinkageType in + guard let self = self else { return } + switch featureLinkageType { + case .playlist: + self.presentPlaylistController() + case .vpn: + self.navigationHelper.openVPNBuyScreen(iapObserver: self.iapObserver) + default: + return + } + }) + Preferences.General.isUsingBottomBar.objectWillChange .receive(on: RunLoop.main) .sink { [weak self] _ in diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/OpenSearch/OpenSearchEngineButton.swift b/Sources/Brave/Frontend/Browser/Search/OpenSearchEngineButton.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/BrowserViewController/OpenSearch/OpenSearchEngineButton.swift rename to Sources/Brave/Frontend/Browser/Search/OpenSearchEngineButton.swift diff --git a/Sources/Brave/Frontend/ClientPreferences.swift b/Sources/Brave/Frontend/ClientPreferences.swift index fff74b178f1..bf0970b379c 100644 --- a/Sources/Brave/Frontend/ClientPreferences.swift +++ b/Sources/Brave/Frontend/ClientPreferences.swift @@ -174,10 +174,6 @@ extension Preferences { /// List of currently installed themes on the device. static let installedCustomThemes = Option<[String]>(key: "newtabpage.installed-custom-themes", default: []) - - /// Tells the app whether we should try to fetch super referrer assets again in case of network error. - public static let superReferrerThemeRetryDeadline = - Option(key: "newtabpage.superreferrer-retry-deadline", default: nil) /// Tells the app whether we should show Privacy Hub in new tab page view controller public static let showNewTabPrivacyHub = diff --git a/Sources/Brave/Frontend/Settings/Debug/RetentionPreferencesDebugMenuViewController.swift b/Sources/Brave/Frontend/Settings/Debug/RetentionPreferencesDebugMenuViewController.swift index 7ebbea1d8c6..36178fcd43d 100644 --- a/Sources/Brave/Frontend/Settings/Debug/RetentionPreferencesDebugMenuViewController.swift +++ b/Sources/Brave/Frontend/Settings/Debug/RetentionPreferencesDebugMenuViewController.swift @@ -11,12 +11,16 @@ import UIKit import BraveUI import Onboarding import BraveCore +import Growth class RetentionPreferencesDebugMenuViewController: TableViewController { private let p3aUtilities: BraveP3AUtils + private let attributionManager: AttributionManager - init(p3aUtilities: BraveP3AUtils) { + init(p3aUtilities: BraveP3AUtils, attributionManager: AttributionManager) { self.p3aUtilities = p3aUtilities + self.attributionManager = attributionManager + super.init(style: .insetGrouped) } @@ -55,7 +59,7 @@ class RetentionPreferencesDebugMenuViewController: TableViewController { .init( text: "Start Onboarding", selection: { [unowned self] in - let onboardingController = WelcomeViewController(state: .loading, p3aUtilities: self.p3aUtilities) + let onboardingController = WelcomeViewController(state: .loading, p3aUtilities: self.p3aUtilities, attributionManager: attributionManager) onboardingController.modalPresentationStyle = .fullScreen present(onboardingController, animated: false) diff --git a/Sources/Brave/Frontend/Settings/SettingsViewController.swift b/Sources/Brave/Frontend/Settings/SettingsViewController.swift index 33995bb6cb6..c212d1bfa97 100644 --- a/Sources/Brave/Frontend/Settings/SettingsViewController.swift +++ b/Sources/Brave/Frontend/Settings/SettingsViewController.swift @@ -58,6 +58,7 @@ class SettingsViewController: TableViewController { private let syncAPI: BraveSyncAPI private let syncProfileServices: BraveSyncProfileServiceIOS private let p3aUtilities: BraveP3AUtils + private let attributionManager: AttributionManager private let keyringStore: KeyringStore? private let cryptoStore: CryptoStore? private let windowProtection: WindowProtection? @@ -73,6 +74,7 @@ class SettingsViewController: TableViewController { rewards: BraveRewards? = nil, windowProtection: WindowProtection?, braveCore: BraveCoreMain, + attributionManager: AttributionManager, keyringStore: KeyringStore? = nil, cryptoStore: CryptoStore? = nil ) { @@ -86,6 +88,7 @@ class SettingsViewController: TableViewController { self.syncAPI = braveCore.syncAPI self.syncProfileServices = braveCore.syncProfileService self.p3aUtilities = braveCore.p3aUtils + self.attributionManager = attributionManager self.keyringStore = keyringStore self.cryptoStore = cryptoStore self.ipfsAPI = braveCore.ipfsAPI @@ -885,7 +888,9 @@ class SettingsViewController: TableViewController { Row( text: "Retention Preferences Debug Menu", selection: { [unowned self] in - self.navigationController?.pushViewController(RetentionPreferencesDebugMenuViewController(p3aUtilities: p3aUtilities), animated: true) + self.navigationController?.pushViewController( + RetentionPreferencesDebugMenuViewController(p3aUtilities: p3aUtilities, attributionManager: attributionManager), + animated: true) }, accessory: .disclosureIndicator, cellClass: MultilineValue1Cell.self), Row( text: "Load all QA Links", diff --git a/Sources/BraveShared/Extensions/URLSessionExtensions.swift b/Sources/BraveShared/Extensions/URLSessionExtensions.swift index 3a503a1b319..2d7b33f0631 100644 --- a/Sources/BraveShared/Extensions/URLSessionExtensions.swift +++ b/Sources/BraveShared/Extensions/URLSessionExtensions.swift @@ -86,7 +86,8 @@ extension URLSession { headers: [String: String] = [:], parameters: [String: Any] = [:], rawData: Data? = nil, - encoding: ParameterEncoding = .query + encoding: ParameterEncoding = .query, + timeout: TimeInterval = 60 ) async throws -> (Any, URLResponse) { do { let request = try buildRequest( @@ -95,7 +96,8 @@ extension URLSession { headers: headers, parameters: parameters, rawData: rawData, - encoding: encoding) + encoding: encoding, + timeoutInterval: timeout) return try await data(for: request) } catch { @@ -126,11 +128,13 @@ extension URLSession { headers: [String: String], parameters: [String: Any], rawData: Data?, - encoding: ParameterEncoding + encoding: ParameterEncoding, + timeoutInterval: TimeInterval = 60 ) throws -> URLRequest { var request = URLRequest(url: url) request.httpMethod = method.rawValue + request.timeoutInterval = timeoutInterval headers.forEach({ request.setValue($0.value, forHTTPHeaderField: $0.key) }) switch encoding { case .textPlain: diff --git a/Sources/BraveStrings/BraveStrings.swift b/Sources/BraveStrings/BraveStrings.swift index a5447cac9db..6655294166f 100644 --- a/Sources/BraveStrings/BraveStrings.swift +++ b/Sources/BraveStrings/BraveStrings.swift @@ -1238,7 +1238,6 @@ extension Strings { public static let requestCaptureDevicePermissionAllowButtonTitle = NSLocalizedString("requestCaptureDevicePermissionAllowButtonTitle", tableName: "BraveShared", bundle: .module, value: "Allow", comment: "A button shown in a permission dialog that will grant the website the ability to use the device's camera or microphone") public static let downloadsMenuItem = NSLocalizedString("DownloadsMenuItem", tableName: "BraveShared", bundle: .module, value: "Downloads", comment: "Title for downloads menu item") public static let downloadsPanelEmptyStateTitle = NSLocalizedString("DownloadsPanelEmptyStateTitle", tableName: "BraveShared", bundle: .module, value: "Downloaded files will show up here.", comment: "Title for when a user has nothing downloaded onto their device, and the list is empty.") - public static let playlistMenuItem = NSLocalizedString("PlaylistMenuItem", tableName: "BraveShared", bundle: .module, value: "Playlist", comment: "Playlist menu item") // MARK: - Themes diff --git a/Sources/Growth/DAU.swift b/Sources/Growth/DAU.swift index c8e64af49f6..17e00fc1c1f 100644 --- a/Sources/Growth/DAU.swift +++ b/Sources/Growth/DAU.swift @@ -90,7 +90,7 @@ public class DAU { return true } - @objc public func sendPingToServerInternal() { + @objc private func sendPingToServerInternal() { guard let paramsAndPrefs = paramsAndPrefsSetup(for: Date()) else { Logger.module.debug("dau, no changes detected, no server ping") DebugLogger.log(for: .urp, text: "dau, no changes detected, no server ping") diff --git a/Sources/Growth/URP/AdAttributionData.swift b/Sources/Growth/URP/AdAttributionReportData.swift similarity index 56% rename from Sources/Growth/URP/AdAttributionData.swift rename to Sources/Growth/URP/AdAttributionReportData.swift index 891a03b9db2..d37c3a47987 100644 --- a/Sources/Growth/URP/AdAttributionData.swift +++ b/Sources/Growth/URP/AdAttributionReportData.swift @@ -5,36 +5,57 @@ import os.log import BraveShared +import Foundation + +public enum SerializationError: Error { + case missing(String) + case invalid(String, Any) +} + +public enum SearchAdError: Error { + case invalidCampaignTokenData + case invalidGroupsReportData + case failedCampaignTokenFetch + case failedCampaignTokenLookup + case missingReportsKeywordParameter + case failedReportsKeywordLookup + case successfulCampaignFailedKeywordLookup(AdAttributionData) +} public struct AdAttributionData { // A value of true returns if a user clicks an Apple Search Ads impression up to 30 days before your app download. // If the API can’t find a matching attribution record, the attribution value is false. - public let attribution: Bool + let attribution: Bool // The identifier of the organization that owns the campaign. // organizationId is the same as your account in the Apple Search Ads UI. - public let organizationId: Int? + let organizationId: Int? // The type of conversion is either Download or Redownload. - public let conversionType: String? + let conversionType: String? // The unique identifier for the campaign. - public let campaignId: Int + let campaignId: Int // The country or region for the campaign. - public let countryOrRegion: String? - - init(attribution: Bool, organizationId: Int? = nil, conversionType: String? = nil, campaignId: Int, countryOrRegion: String? = nil) { + let countryOrRegion: String? + // The ad group if for the campaign which will be used for feature link + let adGroupId: Int? + // The keyword id for the campaign which will be used for feature link + let keywordId: Int? + + init(attribution: Bool, + organizationId: Int? = nil, + conversionType: String? = nil, + campaignId: Int, + countryOrRegion: String? = nil, + adGroupId: Int? = nil, + keywordId: Int? = nil ) { self.attribution = attribution self.organizationId = organizationId self.conversionType = conversionType self.campaignId = campaignId self.countryOrRegion = countryOrRegion + self.adGroupId = adGroupId + self.keywordId = keywordId } -} -enum SerializationError: Error { - case missing(String) - case invalid(String, Any) -} - -extension AdAttributionData { init(json: [String: Any]?) throws { guard let json = json else { throw SerializationError.invalid("Invalid json Dictionary", "") @@ -67,5 +88,39 @@ extension AdAttributionData { self.conversionType = json["conversionType"] as? String self.campaignId = campaignId self.countryOrRegion = json["countryOrRegion"] as? String + self.adGroupId = json["adGroupId"] as? Int + self.keywordId = json["keywordId"] as? Int + } +} + +public struct AdGroupReportData { + public struct KeywordReportResponse: Codable { + let row: [KeywordRow] + } + + public struct KeywordRow: Codable { + let metadata: KeywordMetadata + } + + public struct KeywordMetadata: Codable { + let keyword: String + let keywordId: Int + } + + public let productKeyword: String + + init(data: Data, keywordId: Int) throws { + do { + let decoder = JSONDecoder() + let keywordResponse = try decoder.decode(KeywordReportResponse.self, from: data) + + if let keywordRow = keywordResponse.row.first(where: { $0.metadata.keywordId == keywordId }) { + productKeyword = keywordRow.metadata.keyword + } else { + throw SerializationError.invalid("Keyword with ID \(keywordId) not found", "") + } + } catch { + throw SerializationError.invalid("Invalid json Dictionary", "") + } } } diff --git a/Sources/Growth/URP/AttributionManager.swift b/Sources/Growth/URP/AttributionManager.swift new file mode 100644 index 00000000000..1d55cc51081 --- /dev/null +++ b/Sources/Growth/URP/AttributionManager.swift @@ -0,0 +1,203 @@ +// Copyright 2023 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 Foundation +import Preferences +import Combine +import Shared + +public enum FeatureLinkageType: CaseIterable { + case vpn, playlist, leoAI + + var adKeywords: [String] { + switch self { + case .vpn: + return ["vpn, 1.1.1.1"] + case .playlist: + return ["youtube", "video player", "playlist"] + default: + return [] + } + } + + var campaignIds: [Int] { + switch self { + case .vpn: + return [1475635127, + 1475635126, + 1485804102, + 1480261455, + 1480274213, + 1480244775, + 1485313498, + 1480289866, + 1480327301, + 1480338501, + 1485590008, + 1485843602, + 1480298771, + 1480271827, + 1485600647, + 1484999295, + 1480285450, + 1480274211] + case .playlist: + return [1487969368, + 1489240861, + 1489108255, + 1488070608, + 1487607318, + 1487610419, + 1487610395, + 1488162143, + 1489145748] + default: + return [] + } + } +} + +public enum FeatureLinkageError: Error { + case executionTimeout(AdAttributionData) +} + +public enum FeatureLinkageLogicType { + case reporting, campaingId +} + +public class AttributionManager { + + private let dau: DAU + private let urp: UserReferralProgram + + /// The default Install Referral Code + private let organicInstallReferralCode = "BRV001" + + public let activeFetureLinkageLogic: FeatureLinkageLogicType = .campaingId + + @Published public var adFeatureLinkage: FeatureLinkageType? + + public init(dau: DAU, urp: UserReferralProgram) { + self.dau = dau + self.urp = urp + } + + public func handleReferralLookup(completion: @escaping (URL) -> Void) { + if Preferences.URP.referralLookupOutstanding.value == true { + performProgramReferralLookup(refCode: UserReferralProgram.getReferralCode()) { offerUrl in + guard let url = offerUrl else { return } + completion(url) + } + } else { + urp.pingIfEnoughTimePassed() + } + } + + @discardableResult + @MainActor public func handleSearchAdsInstallAttribution() async throws -> AdAttributionData { + do { + let attributionData = try await urp.adCampaignLookup() + generateReferralCodeAndPingServer(with: attributionData) + + return attributionData + } catch { + throw error + } + } + + @MainActor public func handleSearchAdsFeatureLinkage() async throws -> FeatureLinkageType? { + do { + let attributionData = try await urp.adCampaignLookup(isRetryEnabled: false, timeout: 30) + generateReferralCodeAndPingServer(with: attributionData) + + return fetchFeatureTypes(for: attributionData.campaignId) + } catch { + throw error + } + } + + @MainActor public func handleAdsReportingFeatureLinkage() async throws -> FeatureLinkageType? { + // This function should run multiple tasks first adCampaignLookup + // and adReportsKeywordLookup depending on adCampaignLookup result. + // There is a 60 sec timeout added for adCampaignLookup and will be run with no retry and + // additionally adGroupReportsKeywordLookup API call will be cancelled after 30 sec + + do { + let start = DispatchTime.now() // Start time for time tracking + + // Ad campaign Lookup should be performed with no retry as first step + let attributionData = try await urp.adCampaignLookup(isRetryEnabled: false) + + let elapsedTime = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 + let remainingTime = 1.0 - elapsedTime + + guard remainingTime > 0 else { + throw FeatureLinkageError.executionTimeout(attributionData) + } + + do { + generateReferralCodeAndPingServer(with: attributionData) + + let keyword = try await urp.adReportsKeywordLookup(attributionData: attributionData) + return fetchFeatureTypes(for: keyword) + } catch { + throw(SearchAdError.successfulCampaignFailedKeywordLookup(attributionData)) + } + } catch { + throw error + } + } + + private func fetchFeatureTypes(for keyword: String) -> FeatureLinkageType? { + for linkageType in FeatureLinkageType.allCases where linkageType.adKeywords.contains(keyword) { + return linkageType + } + return nil + } + + private func fetchFeatureTypes(for campaignId: Int) -> FeatureLinkageType? { + for linkageType in FeatureLinkageType.allCases where linkageType.campaignIds.contains(campaignId) { + return linkageType + } + return nil + } + + public func setupReferralCodeAndPingServer(refCode: String? = nil) { + let refCode = refCode ?? organicInstallReferralCode + + // Setting up referral code value + // This value should be set before first DAU ping + Preferences.URP.referralCode.value = refCode + Preferences.URP.installAttributionLookupOutstanding.value = false + + dau.sendPingToServer() + } + + public func generateReferralCodeAndPingServer(with attributionData: AdAttributionData) { + let refCode = generateReferralCode(attributionData: attributionData) + setupReferralCodeAndPingServer(refCode: refCode) + } + + private func performProgramReferralLookup(refCode: String?, completion: @escaping (URL?) -> Void) { + urp.referralLookup(refCode: refCode) { referralCode, offerUrl in + Preferences.URP.referralLookupOutstanding.value = false + + completion(offerUrl?.asURL) + } + } + + private func generateReferralCode(attributionData: AdAttributionData?) -> String { + // Prefix code "001" with BRV for organic iOS installs + var referralCode = organicInstallReferralCode + + if attributionData?.attribution == true, let campaignId = attributionData?.campaignId { + // Adding ASA User refcode prefix to indicate + // Apple Ads Attribution is true + referralCode = "ASA\(String(campaignId))" + } + + return referralCode + } +} diff --git a/Sources/Growth/URP/UrpService.swift b/Sources/Growth/URP/UrpService.swift index 5091bf7a1a0..2be57df8aa0 100644 --- a/Sources/Growth/URP/UrpService.swift +++ b/Sources/Growth/URP/UrpService.swift @@ -25,14 +25,16 @@ struct UrpService { private let host: String private let adServicesURL: String + private let adReportsURL: String private let apiKey: String private let sessionManager: URLSession private let certificateEvaluator: URPCertificatePinningService - init?(host: String, apiKey: String, adServicesURL: String) { + init(host: String, apiKey: String, adServicesURL: String, adReportsURL: String) { self.host = host self.apiKey = apiKey self.adServicesURL = adServicesURL + self.adReportsURL = adReportsURL // Certificate pinning certificateEvaluator = URPCertificatePinningService() @@ -78,7 +80,7 @@ struct UrpService { } } - @MainActor func adCampaignTokenLookupQueue(adAttributionToken: String) async throws -> (AdAttributionData?) { + @MainActor func adCampaignTokenLookupQueue(adAttributionToken: String, isRetryEnabled: Bool = true, timeout: TimeInterval) async throws -> AdAttributionData { guard let endPoint = URL(string: adServicesURL) else { Logger.module.error("AdServicesURLString can not be resolved: \(adServicesURL)") throw URLError(.badURL) @@ -87,7 +89,11 @@ struct UrpService { let attributionDataToken = adAttributionToken.data(using: .utf8) do { - let (result, _) = try await sessionManager.adServicesAttributionApiRequest(endPoint: endPoint, rawData: attributionDataToken) + let (result, _) = try await sessionManager.adServicesAttributionApiRequest( + endPoint: endPoint, + rawData: attributionDataToken, + isRetryEnabled: isRetryEnabled, + timeout: timeout) DebugLogger.log(for: .urp, text: "Ad Attribution response: \(result)") if let resultData = result as? Data { @@ -96,11 +102,38 @@ struct UrpService { return adAttributionData } + + throw SerializationError.invalid("Invalid Data type from response", "") + } catch { + throw error + } + } + + @MainActor func adGroupReportsKeywordLookup(adGroupId: Int, campaignId: Int, keywordId: Int) async throws -> String { + guard let reportsURL = URL(string: adReportsURL) else { + Logger.module.error("AdServicesURLString can not be resolved: \(adReportsURL)") + throw URLError(.badURL) + } + + var endPoint = reportsURL + endPoint.append(pathComponents: "campaigns", "\(campaignId)") + endPoint.append(pathComponents: "adgroups", "\(adGroupId)") + endPoint.append(pathComponents: "keywords", "") + + do { + let (result, _) = try await sessionManager.adGroupsReportApiRequest(endPoint: endPoint) + DebugLogger.log(for: .urp, text: "Ad Groups Report response: \(result)") + + if let resultData = result as? Data { + let adGroupsReportData = try AdGroupReportData(data: resultData, keywordId: keywordId) + + return adGroupsReportData.productKeyword + } + + throw SerializationError.invalid("Invalid Data type from response", "") } catch { throw error } - - return (nil) } func checkIfAuthorizedForGrant(with downloadId: String, completion: @escaping (Bool?, UrpError?) -> Void) { @@ -141,13 +174,23 @@ extension URLSession { } // Apple ad service attricution request requires plain text encoding with post method and passing token as rawdata - func adServicesAttributionApiRequest(endPoint: URL, rawData: Data?) async throws -> (Any, URLResponse) { - // According to attributiontoken API docs - // An error reponse can occur API call is done too quickly after receiving a valid token. - // A best practice is to initiate retries at intervals of 5 seconds, with a maximum of three attempts. - return try await Task.retry(retryCount: 3, retryDelay: 5) { - return try await self.request(endPoint, method: .post, rawData: rawData, encoding: .textPlain) - }.value + func adServicesAttributionApiRequest(endPoint: URL, rawData: Data?, isRetryEnabled: Bool, timeout: TimeInterval) async throws -> (Any, URLResponse) { + // Re-try logic will not be enabled while onboarding happening on first launch + if isRetryEnabled { + // According to attributiontoken API docs + // An error reponse can occur API call is done too quickly after receiving a valid token. + // A best practice is to initiate retries at intervals of 5 seconds, with a maximum of three attempts. + return try await Task.retry(retryCount: 3, retryDelay: 5) { + return try await self.request(endPoint, method: .post, rawData: rawData, encoding: .textPlain) + }.value + } else { + return try await self.request(endPoint, method: .post, rawData: rawData, encoding: .textPlain, timeout: timeout) + } + } + + func adGroupsReportApiRequest(endPoint: URL) async throws -> (Any, URLResponse) { + // Having Reports Keywrod Lookup Endpoint 30 sec timeout + return try await self.request(endPoint, method: .post, encoding: .json, timeout: 30) } } diff --git a/Sources/Growth/URP/UserReferralProgram.swift b/Sources/Growth/URP/UserReferralProgram.swift index a30157f04db..1990a5c717f 100644 --- a/Sources/Growth/URP/UserReferralProgram.swift +++ b/Sources/Growth/URP/UserReferralProgram.swift @@ -24,6 +24,7 @@ public class UserReferralProgram { } let adServicesURLString = "https://api-adservices.apple.com/api/v1/" + let adReportsURLString = "https://api.searchads.apple.com/api/v4/reports/" // In case of network problems when looking for referrral code // we retry the call few times while the app is still alive. @@ -38,20 +39,14 @@ public class UserReferralProgram { let service: UrpService - public init?() { + public init() { // This should _probably_ correspond to the baseUrl for NTPDownloader let host = AppConstants.buildChannel == .debug ? HostUrl.staging : HostUrl.prod - guard - let apiKey = Bundle.main.getPlistString( - for: UserReferralProgram.apiKeyPlistKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) - else { - Logger.module.error("Urp init error, failed to get values from Brave.plist.") - return nil - } + let apiKey = Bundle.main.getPlistString(for: UserReferralProgram.apiKeyPlistKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "apikey" - guard let urpService = UrpService(host: host, apiKey: apiKey, adServicesURL: adServicesURLString) else { return nil } + let urpService = UrpService(host: host, apiKey: apiKey, adServicesURL: adServicesURLString, adReportsURL: adReportsURLString) DebugLogger.log(for: .urp, text: "URP init, host: \(host)") @@ -121,27 +116,44 @@ public class UserReferralProgram { service.referralCodeLookup(refCode: refCode, completion: referralBlock) } - public func adCampaignLookup(completion: @escaping ((AdAttributionData)?, Error?) -> Void) { + @MainActor public func adCampaignLookup(isRetryEnabled: Bool = true, timeout: TimeInterval = 60) async throws -> AdAttributionData { // Fetching ad attibution token do { let adAttributionToken = try AAAttribution.attributionToken() - Task { @MainActor in - do { - let result = try await service.adCampaignTokenLookupQueue(adAttributionToken: adAttributionToken) - completion(result, nil) - } catch { - Logger.module.info("Could not retrieve ad campaign attibution from ad services") - completion(nil, error) - } + do { + return try await service.adCampaignTokenLookupQueue( + adAttributionToken: adAttributionToken, + isRetryEnabled: isRetryEnabled, + timeout: timeout) + } catch { + Logger.module.info("Could not retrieve ad campaign attibution from ad services") + throw SearchAdError.invalidCampaignTokenData } } catch { Logger.module.info("Couldnt fetch attribute tokens with error: \(error)") - completion(nil, error) - return + throw SearchAdError.failedCampaignTokenFetch } } + @MainActor func adReportsKeywordLookup(attributionData: AdAttributionData) async throws -> String { + guard let adGroupId = attributionData.adGroupId, let keywordId = attributionData.keywordId else { + Logger.module.info("Could not retrieve ad campaign attibution from ad services") + throw SearchAdError.missingReportsKeywordParameter + } + + do { + return try await service.adGroupReportsKeywordLookup( + adGroupId: adGroupId, + campaignId: attributionData.campaignId, + keywordId: keywordId) + + } catch { + Logger.module.info("Could not retrieve ad groups reports using ad services") + throw SearchAdError.failedReportsKeywordLookup + } + } + private func initRetryPingConnection(numberOfTimes: Int32) { if AppConstants.buildChannel.isPublic { // Adding some time offset to be extra safe. @@ -230,7 +242,8 @@ public class UserReferralProgram { DebugLogger.log(for: .urp, text: "Enough time has passed, removing referral code data") return nil } else if let referralCode = Preferences.URP.referralCode.value { - // Appending ref code to dau ping if user used installed the app via user referral program. + // Appending ref code to dau ping if user used installed the app via + // user referral program or apple search ad if Preferences.URP.referralCodeDeleteDate.value == nil { DebugLogger.log(for: .urp, text: "Setting new date for deleting referral code.") let timeToDelete = AppConstants.buildChannel.isPublic ? 90.days : 20.minutes diff --git a/Sources/Onboarding/Welcome/WelcomeViewCallout.swift b/Sources/Onboarding/Welcome/WelcomeViewCallout.swift index 6c3af0d4272..84619648ef7 100644 --- a/Sources/Onboarding/Welcome/WelcomeViewCallout.swift +++ b/Sources/Onboarding/Welcome/WelcomeViewCallout.swift @@ -124,8 +124,15 @@ class WelcomeViewCallout: UIView { } private let primaryButton = RoundInterfaceButton(type: .custom).then { - $0.setTitleColor(.white, for: .normal) - $0.backgroundColor = .braveBlurpleTint + $0.configuration = .filled() + $0.configuration?.showsActivityIndicator = false + $0.configuration?.imagePadding = 5 + $0.configuration?.activityIndicatorColorTransformer = UIConfigurationColorTransformer({ _ in + .white + }) + $0.configuration?.baseForegroundColor = .white + $0.configuration?.baseBackgroundColor = .braveBlurpleTint + $0.titleLabel?.numberOfLines = 0 $0.titleLabel?.minimumScaleFactor = 0.7 $0.titleLabel?.adjustsFontSizeToFitWidth = true @@ -169,6 +176,12 @@ class WelcomeViewCallout: UIView { arrowView.isHidden = isBottomArrowHidden } } + + var isLoading = false { + didSet { + primaryButton.setNeedsUpdateConfiguration() + } + } init() { super.init(frame: .zero) @@ -488,7 +501,7 @@ class WelcomeViewCallout: UIView { } primaryButton.do { - $0.setTitle(info.primaryButtonTitle, for: .normal) + $0.configuration?.title = info.primaryButtonTitle $0.titleLabel?.font = .preferredFont(for: .body, weight: .regular) $0.addAction( UIAction( @@ -498,6 +511,10 @@ class WelcomeViewCallout: UIView { }), for: .touchUpInside) $0.alpha = 1.0 $0.isHidden = false + $0.configurationUpdateHandler = { button in + button.configuration?.title = self.isLoading ? "" : info.primaryButtonTitle + button.configuration?.showsActivityIndicator = self.isLoading + } } secondaryLabel.do { diff --git a/Sources/Onboarding/Welcome/WelcomeViewController.swift b/Sources/Onboarding/Welcome/WelcomeViewController.swift index bfab1d61fbf..8e651e10872 100644 --- a/Sources/Onboarding/Welcome/WelcomeViewController.swift +++ b/Sources/Onboarding/Welcome/WelcomeViewController.swift @@ -13,6 +13,7 @@ import BraveCore import BraveUI import SafariServices import DesignSystem +import Growth private enum WelcomeViewID: Int { case background = 1 @@ -27,15 +28,17 @@ private enum WelcomeViewID: Int { public class WelcomeViewController: UIViewController { private var state: WelcomeViewCalloutState? - private let p3aUtilities: BraveP3AUtils + private let p3aUtilities: BraveP3AUtils // Privacy Analytics + private let attributionManager: AttributionManager // Manager to handle daily active user and user referral - public convenience init(p3aUtilities: BraveP3AUtils) { - self.init(state: .loading, p3aUtilities: p3aUtilities) + public convenience init(p3aUtilities: BraveP3AUtils, attributionManager: AttributionManager) { + self.init(state: .loading, p3aUtilities: p3aUtilities, attributionManager: attributionManager) } - public init(state: WelcomeViewCalloutState?, p3aUtilities: BraveP3AUtils) { + public init(state: WelcomeViewCalloutState?, p3aUtilities: BraveP3AUtils, attributionManager: AttributionManager) { self.state = state self.p3aUtilities = p3aUtilities + self.attributionManager = attributionManager super.init(nibName: nil, bundle: nil) self.transitioningDelegate = self @@ -316,14 +319,14 @@ public class WelcomeViewController: UIViewController { } private func animateToWelcomeState() { - let nextController = WelcomeViewController(state: nil, p3aUtilities: self.p3aUtilities).then { + let nextController = WelcomeViewController(state: nil, p3aUtilities: p3aUtilities, attributionManager: attributionManager).then { $0.setLayoutState(state: WelcomeViewCalloutState.welcome(title: Strings.Onboarding.welcomeScreenTitle)) } present(nextController, animated: true) } private func animateToDefaultBrowserState() { - let nextController = WelcomeViewController(state: nil, p3aUtilities: self.p3aUtilities) + let nextController = WelcomeViewController(state: nil, p3aUtilities: p3aUtilities, attributionManager: attributionManager) let state = WelcomeViewCalloutState.defaultBrowser( info: WelcomeViewCalloutState.WelcomeViewDefaultBrowserDetails( title: Strings.Callout.defaultBrowserCalloutTitle, @@ -344,7 +347,7 @@ public class WelcomeViewController: UIViewController { } private func animateToDefaultSettingsState() { - let nextController = WelcomeViewController(state: nil, p3aUtilities: self.p3aUtilities).then { + let nextController = WelcomeViewController(state: nil, p3aUtilities: p3aUtilities, attributionManager: attributionManager).then { $0.setLayoutState( state: WelcomeViewCalloutState.settings( title: Strings.Onboarding.navigateSettingsOnboardingScreenTitle, @@ -357,7 +360,7 @@ public class WelcomeViewController: UIViewController { } private func animateToP3aState() { - let nextController = WelcomeViewController(state: nil, p3aUtilities: self.p3aUtilities) + let nextController = WelcomeViewController(state: nil, p3aUtilities: p3aUtilities, attributionManager: attributionManager) let state = WelcomeViewCalloutState.p3a( info: WelcomeViewCalloutState.WelcomeViewDefaultBrowserDetails( title: Strings.Callout.p3aCalloutTitle, @@ -375,8 +378,12 @@ public class WelcomeViewController: UIViewController { nextController.present(p3aLearnMoreController, animated: true) }, - primaryButtonAction: { [weak self] in - self?.close() + primaryButtonAction: { [weak nextController, weak self] in + guard let controller = nextController, let self = self else { + return + } + + self.handleAdReportingFeatureLinkage(with: controller) } ) ) @@ -389,6 +396,72 @@ public class WelcomeViewController: UIViewController { } } + private func handleAdReportingFeatureLinkage(with controller: WelcomeViewController) { + // Check controller is not in loading state + guard !controller.calloutView.isLoading else { + return + } + // The loading state should start before calling API + controller.calloutView.isLoading = true + + let attributionManager = controller.attributionManager + + Task { @MainActor in + do { + if controller.p3aUtilities.isP3AEnabled { + switch attributionManager.activeFetureLinkageLogic { + case .campaingId: + let featureType = try await attributionManager.handleSearchAdsFeatureLinkage() + attributionManager.adFeatureLinkage = featureType + case .reporting: + // Handle API calls and send linkage type + let featureType = try await controller.attributionManager.handleAdsReportingFeatureLinkage() + attributionManager.adFeatureLinkage = featureType + } + } else { + // p3a consent is not given + attributionManager.setupReferralCodeAndPingServer() + } + + controller.calloutView.isLoading = false + close() + } catch FeatureLinkageError.executionTimeout(let attributionData) { + // Time out occurred while executing ad reports lookup + // Ad Campaign Lookup is successful so dau server should be pinged + // attribution data referral code + await pingServerWithGeneratedReferralCode( + using: attributionData, controller: controller) + } catch SearchAdError.successfulCampaignFailedKeywordLookup(let attributionData) { + // Error occurred while executing ad reports lookup + // Ad Campaign Lookup is successful so dau server should be pinged + // attribution data referral code + await pingServerWithGeneratedReferralCode( + using: attributionData, controller: controller) + } catch { + // Error occurred before getting successful + // attributuion data, generic code should be pinged + attributionManager.setupReferralCodeAndPingServer() + + controller.calloutView.isLoading = false + close() + } + } + } + + private func pingServerWithGeneratedReferralCode(using attributionData: AdAttributionData, controller: WelcomeViewController) async { + Task { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + controller.attributionManager.generateReferralCodeAndPingServer(with: attributionData) + continuation.resume() + } + } + } + + controller.calloutView.isLoading = false + close() + } + private func onSetDefaultBrowser() { guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else { return diff --git a/Sources/Preferences/GlobalPreferences.swift b/Sources/Preferences/GlobalPreferences.swift index 36838f7916b..cc0a54bcec3 100644 --- a/Sources/Preferences/GlobalPreferences.swift +++ b/Sources/Preferences/GlobalPreferences.swift @@ -140,6 +140,11 @@ extension Preferences { /// /// This is used to determine if a promoted purchase from store can be triggered and shown user public static let isOnboardingActive = Option(key: "appstate.onboarding-active", default: false) + + /// A cached value for indicating if dau ping is awaiting for p3a choice on onboarding + /// + /// This is used to determine if a user gave consent to p3a in onboarding and dau ping can fetch referral code from Apple API + public static let dailyUserPingAwaitingUserConsent = Option(key: "appstate.dau-awaiting", default: false) } public final class Chromium {