Skip to content
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

Basic NetP remote messaging #1665

Merged
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6acc3b6
Remove the hardcoded beta ended notice for NetP.
samsymons Sep 20, 2023
34f735f
Begin adding Network Protection remote messaging.
samsymons Sep 20, 2023
70aa93f
Continue on the remote messaging implementation.
samsymons Sep 21, 2023
4c662c3
Get remote cards working.
samsymons Sep 21, 2023
92abdc5
Add debug options for the NetP activation date.
samsymons Sep 22, 2023
a2892b1
Implement pixels.
samsymons Sep 22, 2023
996f916
Add extra pixels.
samsymons Sep 22, 2023
ba772e1
Merge branch 'develop' into sam/add-netp-messaging-support-part-1-rem…
samsymons Sep 24, 2023
31cf1ec
Merge branch 'sam/add-netp-messaging-support-part-1-remove-hardcoded-…
samsymons Sep 24, 2023
e61d493
Resolve the daily pixel TODO.
samsymons Sep 24, 2023
6ab096d
Resolve a compiler warning.
samsymons Sep 24, 2023
de976a5
Rate limit how often the app can fetch NetP messages.
samsymons Sep 24, 2023
893f913
Begin filling out the test suite.
samsymons Sep 24, 2023
8efadfb
Avoid running NetP tests in App Store builds.
samsymons Sep 24, 2023
e69499d
Fix App Store builds.
samsymons Sep 25, 2023
d541e04
Unit test the main remote messaging class.
samsymons Sep 25, 2023
b4ff88a
Get the App Store test suite compiling.
samsymons Sep 25, 2023
a0aafd1
Add debug menu option to open the app container
samsymons Sep 25, 2023
bb57b9e
Fix a keychain storage bug
samsymons Sep 25, 2023
e0863fa
Add a comment for RateLimitedOperation.
samsymons Sep 27, 2023
8716068
Add reset options for NetP remote messaging.
samsymons Sep 27, 2023
69a6e03
Add a way to reset daily pixels.
samsymons Sep 27, 2023
b092233
Simplify the continue set up model.
samsymons Sep 27, 2023
4b5551a
Merge branch 'develop' into sam/add-netp-messaging-support-part-2-add…
samsymons Sep 27, 2023
3f40950
Remove RateLimitedOperation.
samsymons Sep 27, 2023
6409e9b
Merge branch 'develop' into sam/add-netp-messaging-support-part-2-add…
samsymons Sep 28, 2023
5bfe658
Add survey URL parameters.
samsymons Sep 29, 2023
e35c925
Merge branch 'develop' into sam/add-netp-messaging-support-part-2-add…
samsymons Oct 2, 2023
793c4f3
Fix unit tests.
samsymons Oct 2, 2023
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
82 changes: 82 additions & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,6 @@ extension UserText {
static let networkProtectionWaitlistButtonJoinWaitlist = NSLocalizedString("network-protection.waitlist.button.join-waitlist", value: "Join the Waitlist", comment: "Join Waitlist button for Network Protection join waitlist screen")
static let networkProtectionWaitlistButtonAgreeAndContinue = NSLocalizedString("network-protection.waitlist.button.agree-and-continue", value: "Agree and Continue", comment: "Agree and Continue button for Network Protection join waitlist screen")

static let networkProtectionBetaEndedCardTitle = NSLocalizedString("network-protection.waitlist.beta-ended-card.title", value: "VPN Beta Closed", comment: "Title for the Network Protection beta ended card")
static let networkProtectionBetaEndedCardText = NSLocalizedString("network-protection.waitlist.beta-ended-card.text", value: "Thank you for participating! We look forward to sharing more with you in future product announcements.", comment: "Text for the Network Protection beta ended card")
static let networkProtectionBetaEndedCardAction = NSLocalizedString("network-protection.waitlist.beta-ended-card.action", value: "Dismiss", comment: "Action text for the Network Protection beta ended card")

}

// MARK: - Network Protection Terms of Service
Expand Down
96 changes: 96 additions & 0 deletions DuckDuckGo/Common/Utilities/RateLimitedOperation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//
// RateLimitedOperation.swift
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

typealias RateLimitedOperationCompletion = () -> Void

protocol RateLimitedOperation {
func performRateLimitedOperation(operationName: String, operation: (@escaping RateLimitedOperationCompletion) -> Void)
}

/// This class defines a way to run a block of code no more frequently than the interval you specify. For example, you may want to have a function that fetches some remote resource occur no more
/// frequently than every 8 hours.
///
/// - Note: This class does not yet support a way to indicate whether the operation succeeded. A future enhancement would be to add this, so that the operation can try again in the case of failure.
samsymons marked this conversation as resolved.
Show resolved Hide resolved
final class UserDefaultsRateLimitedOperation: RateLimitedOperation {

enum Constants {
static let userDefaultsPreviewKey = "rate-limited-operation.last-operation-timestamp"
}

private let minimumTimeSinceLastOperation: TimeInterval
private let userDefaults: UserDefaults

convenience init(debug: TimeInterval, release: TimeInterval) {
#if DEBUG || REVIEW
self.init(minimumTimeSinceLastOperation: debug)
#else
self.init(minimumTimeSinceLastOperation: release)
#endif
}

init(minimumTimeSinceLastOperation: TimeInterval, userDefaults: UserDefaults = .standard) {
self.minimumTimeSinceLastOperation = minimumTimeSinceLastOperation
self.userDefaults = userDefaults
}

// MARK: - RateLimitedOperation

func performRateLimitedOperation(operationName: String, operation: (@escaping RateLimitedOperationCompletion) -> Void) {
// First, check whether there is an existing last refresh date and if it's greater than the current date when adding the minimum refresh time.
if let lastRefreshDate = lastRefreshDate(forOperationName: operationName),
lastRefreshDate.addingTimeInterval(minimumTimeSinceLastOperation) > Date() {
return
}

operation {
self.updateLastRefreshDate(forOperationName: operationName)
}
}

private func lastRefreshDate(forOperationName operationName: String) -> Date? {
let key = userDefaultsKey(operationName: operationName)

guard let object = userDefaults.object(forKey: key) else {
return nil
}

guard let date = object as? Date else {
assertionFailure("Got rate limited date, but couldn't convert it to Date")
return nil
}

return date
}

private func updateLastRefreshDate(forOperationName operationName: String) {
let key = userDefaultsKey(operationName: operationName)
userDefaults.setValue(Date(), forKey: key)
}

private func userDefaultsKey(operationName: String) -> String {
return "\(Constants.userDefaultsPreviewKey).\(operationName)"
}

func resetTimestamp(forOperationName name: String) {
let key = userDefaultsKey(operationName: name)
userDefaults.removeObject(forKey: key)
}

}
2 changes: 1 addition & 1 deletion DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ public struct UserDefaultsWrapper<T> {
case homePageIsContinueSetupVisible = "home.page.is.continue.setup.visible"
case homePageIsRecentActivityVisible = "home.page.is.recent.activity.visible"
case homePageIsFirstSession = "home.page.is.first.session"
case homePageShowNetworkProtectionBetaEndedNotice = "home.page.network-protection.show-beta-ended-notice"

case appIsRelaunchingAutomatically = "app-relaunching-automatically"

Expand Down Expand Up @@ -176,6 +175,7 @@ public struct UserDefaultsWrapper<T> {
enum RemovedKeys: String, CaseIterable {
case passwordManagerDoNotPromptDomains = "com.duckduckgo.passwordmanager.do-not-prompt-domains"
case incrementalFeatureFlagTestHasSentPixel = "network-protection.incremental-feature-flag-test.has-sent-pixel"
case homePageShowNetworkProtectionBetaEndedNotice = "home.page.network-protection.show-beta-ended-notice"
}

private let key: Key
Expand Down
111 changes: 44 additions & 67 deletions DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ extension HomePage.Models {
let itemsRowCountWhenCollapsed = HomePage.featureRowCountWhenCollapsed
let gridWidth = FeaturesGridDimensions.width
let deleteActionTitle = UserText.newTabSetUpRemoveItemAction
let networkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging
let privacyConfigurationManager: PrivacyConfigurationManaging

var isDay0SurveyEnabled: Bool {
Expand Down Expand Up @@ -103,9 +104,6 @@ extension HomePage.Models {
@UserDefaultsWrapper(key: .homePageShowSurveyDay7, defaultValue: true)
private var shouldShowSurveyDay7: Bool

@UserDefaultsWrapper(key: .homePageShowNetworkProtectionBetaEndedNotice, defaultValue: true)
private var shouldShowNetworkProtectionBetaEndedNotice: Bool

@UserDefaultsWrapper(key: .homePageIsFirstSession, defaultValue: true)
private var isFirstSession: Bool

Expand Down Expand Up @@ -139,6 +137,7 @@ extension HomePage.Models {
privacyPreferences: PrivacySecurityPreferences = PrivacySecurityPreferences.shared,
cookieConsentPopoverManager: CookieConsentPopoverManager = CookieConsentPopoverManager(),
duckPlayerPreferences: DuckPlayerPreferencesPersistor,
networkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging,
privacyConfigurationManager: PrivacyConfigurationManaging = AppPrivacyFeatures.shared.contentBlocking.privacyConfigurationManager) {
self.defaultBrowserProvider = defaultBrowserProvider
self.dataImportProvider = dataImportProvider
Expand All @@ -147,6 +146,7 @@ extension HomePage.Models {
self.privacyPreferences = privacyPreferences
self.cookieConsentPopoverManager = cookieConsentPopoverManager
self.duckPlayerPreferences = duckPlayerPreferences
self.networkProtectionRemoteMessaging = networkProtectionRemoteMessaging
self.privacyConfigurationManager = privacyConfigurationManager
refreshFeaturesMatrix()
NotificationCenter.default.addObserver(self, selector: #selector(newTabOpenNotification(_:)), name: HomePage.Models.newHomePageTabOpen, object: nil)
Expand Down Expand Up @@ -188,8 +188,18 @@ extension HomePage.Models {
visitSurvey(day: .day0)
case .surveyDay7:
visitSurvey(day: .day7)
case .networkProtectionBetaEndedNotice:
removeItem(for: .networkProtectionBetaEndedNotice)
case .networkProtectionRemoteMessage(let message):
if let surveyURLString = message.surveyURL, let surveyURL = URL(string: surveyURLString) {
let tab = Tab(content: .url(surveyURL), shouldLoadInBackground: true)
tabCollectionViewModel.append(tab: tab)
Pixel.fire(.networkProtectionRemoteMessageOpened(messageID: message.id))
} else {
Pixel.fire(.networkProtectionRemoteMessageDismissed(messageID: message.id))
}

// Dismiss the message after the user opens the survey, even if they just close the tab immediately afterwards.
networkProtectionRemoteMessaging.dismiss(message: message)
refreshFeaturesMatrix()
samsymons marked this conversation as resolved.
Show resolved Hide resolved
}
}
// swiftlint:enable cyclomatic_complexity
Expand All @@ -210,8 +220,9 @@ extension HomePage.Models {
shouldShowSurveyDay0 = false
case .surveyDay7:
shouldShowSurveyDay7 = false
case .networkProtectionBetaEndedNotice:
shouldShowNetworkProtectionBetaEndedNotice = false
case .networkProtectionRemoteMessage(let message):
networkProtectionRemoteMessaging.dismiss(message: message)
Pixel.fire(.networkProtectionRemoteMessageDismissed(messageID: message.id))
}
refreshFeaturesMatrix()
}
Expand All @@ -220,8 +231,13 @@ extension HomePage.Models {
func refreshFeaturesMatrix() {
var features: [FeatureType] = []

if shouldNetworkProtectionBetaEndedNoticeBeVisible {
features.append(.networkProtectionBetaEndedNotice)
for message in networkProtectionRemoteMessaging.presentableRemoteMessages() {
features.append(.networkProtectionRemoteMessage(message))
DailyPixel.fire(
pixel: .networkProtectionRemoteMessageDisplayed(messageID: message.id),
frequency: .dailyOnly,
includeAppVersionParameter: true
)
}

for feature in listOfFeatures {
Expand Down Expand Up @@ -254,8 +270,8 @@ extension HomePage.Models {
if shouldSurveyDay7BeVisible {
features.append(feature)
}
case .networkProtectionBetaEndedNotice:
break // Do nothing, as the NetP beta ended notice will always be added to the start of the list
case .networkProtectionRemoteMessage:
break // Do nothing, NetP remote messages get appended first
}
}
featuresMatrix = features.chunked(into: itemsPerRow)
Expand Down Expand Up @@ -353,53 +369,6 @@ extension HomePage.Models {
firstLaunchDate <= oneWeekAgo
}

/// The Network Protection beta ended card should only be displayed under the following conditions:
///
/// 1. The user has gone through the waitlist AND used Network Protection at least once
/// 2. The `waitlistBetaActive` flag has been set to disabled
/// 3. The user has not already dismissed the card
private var shouldNetworkProtectionBetaEndedNoticeBeVisible: Bool {
#if NETWORK_PROTECTION
// 1. The user has signed up for the waitlist AND used Network Protection at least once:

let waitlistStorage = NetworkProtectionWaitlist().waitlistStorage
let isWaitlistUser = waitlistStorage.isWaitlistUser && waitlistStorage.isInvited

guard isWaitlistUser else {
return false
}

let activationStore = WaitlistActivationDateStore()
guard activationStore.daysSinceActivation() != nil else {
return false
}

// 2. The `waitlistBetaActive` flag has been set to disabled

let featureOverrides = DefaultWaitlistBetaOverrides()
let waitlistFlagEnabled: Bool

switch featureOverrides.waitlistActive {
case .useRemoteValue:
waitlistFlagEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(NetworkProtectionSubfeature.waitlistBetaActive)
case .on:
waitlistFlagEnabled = true
case .off:
waitlistFlagEnabled = false
}

guard !waitlistFlagEnabled else {
return false
}

// 3. The user has not already dismissed the card

return shouldShowNetworkProtectionBetaEndedNotice
#else
return false
#endif
}

private enum SurveyDay {
case day0
case day7
Expand Down Expand Up @@ -431,15 +400,23 @@ extension HomePage.Models {
}

// MARK: Feature Type
enum FeatureType: CaseIterable {
enum FeatureType: CaseIterable, Equatable, Hashable {

// CaseIterable doesn't work with enums that have associated values, so we have to implement it manually.
// We ignore the `networkProtectionRemoteMessage` case here to avoid it getting accidentally included - it has special handling and will get
// included elsewhere.
static var allCases: [HomePage.Models.FeatureType] {
[.duckplayer, .cookiePopUp, .emailProtection, .defaultBrowser, .importBookmarksAndPasswords, .surveyDay0, .surveyDay7]
}

case duckplayer
case cookiePopUp
case emailProtection
case defaultBrowser
case importBookmarksAndPasswords
case surveyDay0
case surveyDay7
case networkProtectionBetaEndedNotice
case networkProtectionRemoteMessage(NetworkProtectionRemoteMessage)

var title: String {
switch self {
Expand All @@ -457,8 +434,8 @@ extension HomePage.Models {
return UserText.newTabSetUpSurveyDay0CardTitle
case .surveyDay7:
return UserText.newTabSetUpSurveyDay7CardTitle
case .networkProtectionBetaEndedNotice:
return UserText.networkProtectionBetaEndedCardTitle
case .networkProtectionRemoteMessage(let message):
return message.cardTitle
}
}

Expand All @@ -478,8 +455,8 @@ extension HomePage.Models {
return UserText.newTabSetUpSurveyDay0Summary
case .surveyDay7:
return UserText.newTabSetUpSurveyDay7Summary
case .networkProtectionBetaEndedNotice:
return UserText.networkProtectionBetaEndedCardText
case .networkProtectionRemoteMessage(let message):
return message.cardDescription
}
}

Expand All @@ -499,8 +476,8 @@ extension HomePage.Models {
return UserText.newTabSetUpSurveyDay0Action
case .surveyDay7:
return UserText.newTabSetUpSurveyDay7Action
case .networkProtectionBetaEndedNotice:
return UserText.networkProtectionBetaEndedCardAction
case .networkProtectionRemoteMessage(let message):
return message.cardAction
}
}

Expand All @@ -522,7 +499,7 @@ extension HomePage.Models {
return NSImage(named: "Survey-128")!.resized(to: iconSize)!
case .surveyDay7:
return NSImage(named: "Survey-128")!.resized(to: iconSize)!
case .networkProtectionBetaEndedNotice:
case .networkProtectionRemoteMessage:
return NSImage(named: "VPN-Ended")!.resized(to: iconSize)!
}
}
Expand Down
2 changes: 1 addition & 1 deletion DuckDuckGo/HomePage/View/HomePageViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ final class HomePageViewController: NSViewController {
}

func createFeatureModel() -> HomePage.Models.ContinueSetUpModel {
let vm = HomePage.Models.ContinueSetUpModel(defaultBrowserProvider: SystemDefaultBrowserProvider(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), tabCollectionViewModel: tabCollectionViewModel, duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor())
let vm = HomePage.Models.ContinueSetUpModel(defaultBrowserProvider: SystemDefaultBrowserProvider(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), tabCollectionViewModel: tabCollectionViewModel, duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor(), networkProtectionRemoteMessaging: DefaultNetworkProtectionRemoteMessaging())
vm.delegate = self
return vm
}
Expand Down
7 changes: 7 additions & 0 deletions DuckDuckGo/Main/View/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ final class MainViewController: NSViewController {
updateReloadMenuItem()
updateStopMenuItem()
browserTabViewController.windowDidBecomeKey()
refreshNetworkProtectionMessages()
}

func windowDidResignKey() {
Expand All @@ -155,6 +156,12 @@ final class MainViewController: NSViewController {
}
}

private let networkProtectionMessaging = DefaultNetworkProtectionRemoteMessaging()

func refreshNetworkProtectionMessages() {
networkProtectionMessaging.fetchRemoteMessages()
}

override func encodeRestorableState(with coder: NSCoder) {
fatalError("Default AppKit State Restoration should not be used")
}
Expand Down
Loading