diff --git a/BraveRewardsUI/Ads/AdsNotificationHandler.swift b/BraveRewardsUI/Ads/AdsNotificationHandler.swift index cabae9b2a03..8a0bccde2bc 100644 --- a/BraveRewardsUI/Ads/AdsNotificationHandler.swift +++ b/BraveRewardsUI/Ads/AdsNotificationHandler.swift @@ -7,6 +7,7 @@ import UIKit import BraveRewards import pop import SnapKit +import BraveShared public class AdsNotificationHandler: BraveAdsNotificationHandler { /// An action type occuring on the ad @@ -59,6 +60,8 @@ public class AdsNotificationHandler: BraveAdsNotificationHandler { } self.ads.reportNotificationEvent(notification.id, eventType: .viewed) + MonthlyAdsGrantReminder.schedule() + adsViewController.display(ad: notification, handler: { [weak self] (notification, action) in guard let self = self else { return } switch action { diff --git a/BraveRewardsUI/Extensions/BraveLedgerExtensions.swift b/BraveRewardsUI/Extensions/BraveLedgerExtensions.swift index 4e088bcaa6a..ff6a6361cd8 100644 --- a/BraveRewardsUI/Extensions/BraveLedgerExtensions.swift +++ b/BraveRewardsUI/Extensions/BraveLedgerExtensions.swift @@ -5,6 +5,7 @@ import Foundation import BraveRewards import Shared +import BraveShared private let log = Logger.rewardsLogger @@ -205,6 +206,9 @@ extension BraveLedger { self.attestPromotion(promotion.id, solution: solution) { result, promotion in if result == .ledgerOk { + if promotion?.type == .ads { + MonthlyAdsGrantReminder.cancelCurrentMonth() + } self.updatePromotions { completion(true) } diff --git a/BraveShared/BraveStrings.swift b/BraveShared/BraveStrings.swift index 9bb4f3bee5a..822eaad3040 100644 --- a/BraveShared/BraveStrings.swift +++ b/BraveShared/BraveStrings.swift @@ -581,3 +581,9 @@ extension Strings { public static let OBErrorDetails = NSLocalizedString("OBErrorDetails", bundle: Bundle.braveShared, value: "Something went wrong while creating your wallet. Please try again", comment: "A generic error body for onboarding") public static let OBErrorOkay = NSLocalizedString("OBErrorOkay", bundle: Bundle.braveShared, value: "Okay", comment: "") } + +// MARK: - Ads Notifications +extension Strings { + public static let MonthlyAdsClaimNotificationTitle = NSLocalizedString("MonthlyAdsClaimNotificationTitle", bundle: Bundle.braveShared, value: "Claim your Brave ads rewards", comment: "The title of the notification that goes out monthly to users who can claim an ads grant") + public static let MonthlyAdsClaimNotificationBody = NSLocalizedString("MonthlyAdsClaimNotificationBody", bundle: Bundle.braveShared, value: "Time to get rewarded for those ads you've been seeing in Brave.", comment: "The body of the notification that goes out monthly to users who can claim an ads grant") +} diff --git a/BraveShared/Rewards/MonthlyAdsGrantReminder.swift b/BraveShared/Rewards/MonthlyAdsGrantReminder.swift new file mode 100644 index 00000000000..df41785dc1a --- /dev/null +++ b/BraveShared/Rewards/MonthlyAdsGrantReminder.swift @@ -0,0 +1,106 @@ +// 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 Shared +import UserNotifications + +private let log = Logger.rewardsLogger + +/// Manages the scheduling and cancellation of the monthly ad grant reminder notification +final public class MonthlyAdsGrantReminder { + + @available(*, unavailable) + init() { } + + /// The prefix for all notifications scheduled + static private let idPrefix = "rewards.notification.monthly-claim" + + /// Returns true if the given notification is one scheduled by `MonthlyAdsGrantReminder` + static public func isMonthlyAdsReminderNotification(_ notification: UNNotification) -> Bool { + return notification.request.identifier.hasPrefix(idPrefix) + } + + /// Get the identifier for a notification given its month + static private func identifier(for month: Int) -> String { + return "\(idPrefix)-\(month)" + } + + /// The calendar we will use for all date ops for ad grant reminders + static private let calendar = Calendar(identifier: .gregorian) + + /// Cancels the current month's notification if one exists + /// + /// Trigger this when the user claims an ad grant. + static public func cancelCurrentMonth() { + let month = calendar.component(.month, from: Date()) + let id = identifier(for: month) + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequests(withIdentifiers: [id]) + center.removeDeliveredNotifications(withIdentifiers: [id]) + log.debug("Cancelled monthly ad grant reminder for month: \(month)") + } + + /// Retrieve the next month as an integer (1-12). For example, if the current month was december, + /// this would return `1` for January. + static private func nextMonth() -> Int? { + guard let nextMonthsDate = calendar.date(byAdding: .month, value: 1, to: Date()) else { + assertionFailure("Apocalypse...") + return nil + } + return calendar.component(.month, from: nextMonthsDate) + } + + /// Schedules a notification for the following month if one doesn't already exist for that month + /// + /// Trigger this when the user views an ad + static public func schedule() { + guard let month = nextMonth() else { + log.error("Failed to obtain month to schedule notification") + return + } + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.provisional, .alert, .sound, .badge]) { granted, error in + if let error = error { + log.error("Failed to request notifications permissions: \(error)") + return + } + if !granted { + log.info("Not authorized to schedule a notification") + return + } + + let id = self.identifier(for: month) + + center.getPendingNotificationRequests { requests in + if requests.contains(where: { $0.identifier == id }) { + // Already has one scheduled no need to schedule again + return + } + + let content = UNMutableNotificationContent() + content.title = Strings.MonthlyAdsClaimNotificationTitle + content.body = Strings.MonthlyAdsClaimNotificationBody + + let trigger = UNCalendarNotificationTrigger( + dateMatching: .init(calendar: self.calendar, month: month, day: 7, hour: 13, minute: 21), + repeats: false + ) + let request = UNNotificationRequest( + identifier: id, + content: content, + trigger: trigger + ) + center.add(request) { error in + if let error = error { + log.error("Failed to add notification request: \(request) with error: \(error)") + return + } + log.debug("Scheduled monthly ad grant reminder: \(request). Trigger date: \(String(describing: trigger.nextTriggerDate()))") + } + } + } + } +} + diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index afcae39d07f..b0c31335a26 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -284,6 +284,8 @@ 279C756B219DDE3B001CD1CB /* FingerprintingProtection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279C756A219DDE3B001CD1CB /* FingerprintingProtection.swift */; }; 279C75C821A5B37D001CD1CB /* FingerprintingProtection.js in Resources */ = {isa = PBXBuildFile; fileRef = 279C75C021A5B37D001CD1CB /* FingerprintingProtection.js */; }; 27A586E1214C0DDD000CAE3C /* PreferencesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A586E0214C0DDD000CAE3C /* PreferencesTest.swift */; }; + 27AC169323834175004BE19C /* MonthlyAdsGrantReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27AC168F23833A16004BE19C /* MonthlyAdsGrantReminder.swift */; }; + 27AC169823834510004BE19C /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 27AC169623834510004BE19C /* UserNotifications.framework */; }; 27B1E26D235E58190062E86F /* LocaleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27B1E26C235E58190062E86F /* LocaleExtensions.swift */; }; 27C461DE211B76500088A441 /* ShieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C461DD211B76500088A441 /* ShieldsView.swift */; }; 27C46201211CD8D20088A441 /* DeferredTestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A176323020CF2A6000126F25 /* DeferredTestUtils.swift */; }; @@ -1500,6 +1502,8 @@ 279C756A219DDE3B001CD1CB /* FingerprintingProtection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FingerprintingProtection.swift; sourceTree = ""; }; 279C75C021A5B37D001CD1CB /* FingerprintingProtection.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = FingerprintingProtection.js; sourceTree = ""; }; 27A586E0214C0DDD000CAE3C /* PreferencesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesTest.swift; sourceTree = ""; }; + 27AC168F23833A16004BE19C /* MonthlyAdsGrantReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthlyAdsGrantReminder.swift; sourceTree = ""; }; + 27AC169623834510004BE19C /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; 27B1E26C235E58190062E86F /* LocaleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleExtensions.swift; sourceTree = ""; }; 27C461DD211B76500088A441 /* ShieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldsView.swift; sourceTree = ""; }; 27D114D32358FBBF00166534 /* BraveRewardsSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BraveRewardsSettingsViewController.swift; sourceTree = ""; }; @@ -2481,6 +2485,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 27AC169823834510004BE19C /* UserNotifications.framework in Frameworks */, 5DE768A520B3458400FF5533 /* Shared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3208,6 +3213,14 @@ path = Rewards; sourceTree = ""; }; + 27AC169223834134004BE19C /* Rewards */ = { + isa = PBXGroup; + children = ( + 27AC168F23833A16004BE19C /* MonthlyAdsGrantReminder.swift */, + ); + path = Rewards; + sourceTree = ""; + }; 27F443962135E11200296C58 /* BraveShareTo */ = { isa = PBXGroup; children = ( @@ -3858,6 +3871,7 @@ 5DE7688520B3456D00FF5533 /* BraveShared */ = { isa = PBXGroup; children = ( + 27AC169223834134004BE19C /* Rewards */, 0AD4FEE8223A998700E00C05 /* Javascript */, 0AD4FED9223A8AF800E00C05 /* Extensions */, 0AD5E9BB2200590C00D0D91B /* Shields */, @@ -3931,6 +3945,7 @@ 7B604FC11C496005006EEEC3 /* Frameworks */ = { isa = PBXGroup; children = ( + 27AC169623834510004BE19C /* UserNotifications.framework */, 27FA2D4F234CC9FB004D5D2D /* BraveRewards.framework */, 0AC8E23A22A6C56D0064F3FA /* libYubiKit.a */, 272FCA98225CF8F00091E645 /* OnePasswordExtension.framework */, @@ -5810,6 +5825,7 @@ 5DE768A720B345C600FF5533 /* BraveStrings.swift in Sources */, 0AD4FEF2223AA6CA00E00C05 /* JSContextExtensions.swift in Sources */, 0AD4FEF4223AC32200E00C05 /* JSValueExtensions.swift in Sources */, + 27AC169323834175004BE19C /* MonthlyAdsGrantReminder.swift in Sources */, 0AD4FEEE223AA09D00E00C05 /* BrowserifyExposable.swift in Sources */, 0A917388231D11960069A08B /* AppReview.swift in Sources */, 5D6DDEF3214003A6001FF0AE /* DAU.swift in Sources */, diff --git a/Client/Application/Delegates/AppDelegate.swift b/Client/Application/Delegates/AppDelegate.swift index 01c5cb7dad7..876f9aac072 100644 --- a/Client/Application/Delegates/AppDelegate.swift +++ b/Client/Application/Delegates/AppDelegate.swift @@ -84,6 +84,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UIViewControllerRestorati @discardableResult fileprivate func startApplication(_ application: UIApplication, withLaunchOptions launchOptions: [AnyHashable: Any]?) -> Bool { log.info("startApplication begin") + UNUserNotificationCenter.current().delegate = self + // Set the Firefox UA for browsing. setUserAgent() @@ -488,3 +490,19 @@ extension AppDelegate: MFMailComposeViewControllerDelegate { startApplication(application!, withLaunchOptions: self.launchOptions) } } + +extension AppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + if MonthlyAdsGrantReminder.isMonthlyAdsReminderNotification(response.notification) { + // Open the rewards panel, showing the user their grant + if UIApplication.shared.applicationState != .active { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + // Give the UI a chance to be put together first + self.browserViewController.showBraveRewardsPanel() + } + } else { + browserViewController.showBraveRewardsPanel() + } + } + } +} diff --git a/Client/Frontend/Browser/BrowserViewController/BVC+Rewards.swift b/Client/Frontend/Browser/BrowserViewController/BVC+Rewards.swift index 3a9d4d6242c..f1bd2aff799 100644 --- a/Client/Frontend/Browser/BrowserViewController/BVC+Rewards.swift +++ b/Client/Frontend/Browser/BrowserViewController/BVC+Rewards.swift @@ -92,6 +92,8 @@ extension BrowserViewController { } // Hide the current tab rewards.ledger.selectedTabId = 0 + // Fetch new promotions + rewards.ledger.fetchPromotions(nil) } }