Skip to content

Commit

Permalink
DBP remote messaging (#1997)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/0/1206209106966759/f
Tech Design URL:
CC:

Description:

This PR adds support for showing remote messages to DBP users.
  • Loading branch information
samsymons authored Jan 10, 2024
1 parent 10beefe commit 09113e8
Show file tree
Hide file tree
Showing 28 changed files with 928 additions and 305 deletions.
80 changes: 60 additions & 20 deletions DuckDuckGo.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions DuckDuckGo/Common/Surveys/HomePageRemoteMessagingRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//
// HomePageRemoteMessagingRequest.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
import Networking

protocol HomePageRemoteMessagingRequest {

func fetchHomePageRemoteMessages<T: Decodable>(completion: @escaping (Result<[T], Error>) -> Void)

}

final class DefaultHomePageRemoteMessagingRequest: HomePageRemoteMessagingRequest {

enum NetworkProtectionEndpoint {
case debug
case production

var url: URL {
switch self {
case .debug: return URL(string: "https://staticcdn.duckduckgo.com/macos-desktop-browser/network-protection/messages-v2-debug.json")!
case .production: return URL(string: "https://staticcdn.duckduckgo.com/macos-desktop-browser/network-protection/messages-v2.json")!
}
}
}

enum DataBrokerProtectionEndpoint {
case debug
case production

var url: URL {
switch self {
case .debug: return URL(string: "https://staticcdn.duckduckgo.com/macos-desktop-browser/dbp/messages-debug.json")!
case .production: return URL(string: "https://staticcdn.duckduckgo.com/macos-desktop-browser/dbp/messages.json")!
}
}
}

enum HomePageRemoteMessagingRequestError: Error {
case failedToDecodeMessages
case requestCompletedWithoutErrorOrResponse
}

static func networkProtectionMessagesRequest() -> HomePageRemoteMessagingRequest {
#if DEBUG || REVIEW
return DefaultHomePageRemoteMessagingRequest(endpointURL: NetworkProtectionEndpoint.debug.url)
#else
return DefaultHomePageRemoteMessagingRequest(endpointURL: NetworkProtectionEndpoint.production.url)
#endif
}

static func dataBrokerProtectionMessagesRequest() -> HomePageRemoteMessagingRequest {
#if DEBUG || REVIEW
return DefaultHomePageRemoteMessagingRequest(endpointURL: DataBrokerProtectionEndpoint.debug.url)
#else
return DefaultHomePageRemoteMessagingRequest(endpointURL: DataBrokerProtectionEndpoint.production.url)
#endif
}

private let endpointURL: URL

init(endpointURL: URL) {
self.endpointURL = endpointURL
}

func fetchHomePageRemoteMessages<T: Decodable>(completion: @escaping (Result<[T], Error>) -> Void) {
let httpMethod = APIRequest.HTTPMethod.get
let configuration = APIRequest.Configuration(url: endpointURL, method: httpMethod, body: nil)
let request = APIRequest(configuration: configuration)

request.fetch { response, error in
if let error {
completion(Result.failure(error))
} else if let responseData = response?.data {
do {
let decoder = JSONDecoder()
let decoded = try decoder.decode([T].self, from: responseData)
completion(Result.success(decoded))
} catch {
completion(.failure(HomePageRemoteMessagingRequestError.failedToDecodeMessages))
}
} else {
completion(.failure(HomePageRemoteMessagingRequestError.requestCompletedWithoutErrorOrResponse))
}
}
}

}
115 changes: 115 additions & 0 deletions DuckDuckGo/Common/Surveys/HomePageRemoteMessagingStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//
// HomePageRemoteMessagingStorage.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

protocol HomePageRemoteMessagingStorage {

func store<Message: Codable>(messages: [Message]) throws
func storedMessages<Message: Codable>() -> [Message]

func dismissRemoteMessage(with id: String)
func dismissedMessageIDs() -> [String]

}

final class DefaultHomePageRemoteMessagingStorage: HomePageRemoteMessagingStorage {

enum NetworkProtectionConstants {
static let dismissedMessageIdentifiersKey = "home.page.network-protection.dismissed-message-identifiers"
static let networkProtectionMessagesFileName = "network-protection-messages.json"
}

enum DataBrokerProtectionConstants {
static let dismissedMessageIdentifiersKey = "home.page.dbp.dismissed-message-identifiers"
static let networkProtectionMessagesFileName = "dbp-messages.json"
}

private let userDefaults: UserDefaults
private let messagesURL: URL
private let dismissedMessageIdentifiersKey: String

private static var applicationSupportURL: URL {
URL.sandboxApplicationSupportURL
}

static func networkProtection() -> DefaultHomePageRemoteMessagingStorage {
return DefaultHomePageRemoteMessagingStorage(
messagesFileName: NetworkProtectionConstants.networkProtectionMessagesFileName,
dismissedMessageIdentifiersKey: NetworkProtectionConstants.dismissedMessageIdentifiersKey
)
}

static func dataBrokerProtection() -> DefaultHomePageRemoteMessagingStorage {
return DefaultHomePageRemoteMessagingStorage(
messagesFileName: DataBrokerProtectionConstants.networkProtectionMessagesFileName,
dismissedMessageIdentifiersKey: DataBrokerProtectionConstants.dismissedMessageIdentifiersKey
)
}

init(
userDefaults: UserDefaults = .standard,
messagesDirectoryURL: URL = DefaultHomePageRemoteMessagingStorage.applicationSupportURL,
messagesFileName: String,
dismissedMessageIdentifiersKey: String
) {
self.userDefaults = userDefaults
self.messagesURL = messagesDirectoryURL.appendingPathComponent(messagesFileName)
self.dismissedMessageIdentifiersKey = dismissedMessageIdentifiersKey
}

func store<Message: Codable>(messages: [Message]) throws {
let encoded = try JSONEncoder().encode(messages)
try encoded.write(to: messagesURL)
}

func storedMessages<Message: Codable>() -> [Message] {
do {
let messagesData = try Data(contentsOf: messagesURL)
let messages = try JSONDecoder().decode([Message].self, from: messagesData)

return messages
} catch {
// Errors can occur if the file doesn't exist or the schema changed, in which case the app will fetch the file again later and
// overwrite it.
return []
}
}

func dismissRemoteMessage(with id: String) {
var dismissedMessages = dismissedMessageIDs()

guard !dismissedMessages.contains(id) else {
return
}

dismissedMessages.append(id)
userDefaults.set(dismissedMessages, forKey: dismissedMessageIdentifiersKey)
}

func dismissedMessageIDs() -> [String] {
let messages = userDefaults.array(forKey: dismissedMessageIdentifiersKey) as? [String]
return messages ?? []
}

func removeStoredAndDismissedMessages() {
userDefaults.removeObject(forKey: dismissedMessageIdentifiersKey)
try? FileManager.default.removeItem(at: messagesURL)
}

}
108 changes: 108 additions & 0 deletions DuckDuckGo/Common/Surveys/SurveyURLBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//
// SurveyURLBuilder.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
import Common

final class SurveyURLBuilder {

enum SurveyURLParameters: String, CaseIterable {
case atb = "atb"
case atbVariant = "var"
case daysSinceActivated = "delta"
case macOSVersion = "mv"
case appVersion = "ddgv"
case hardwareModel = "mo"
case lastDayActive = "da"
}

private let statisticsStore: StatisticsStore
private let operatingSystemVersion: String
private let appVersion: String
private let hardwareModel: String?
private let daysSinceActivation: Int?
private let daysSinceLastActive: Int?

init(statisticsStore: StatisticsStore,
operatingSystemVersion: String,
appVersion: String,
hardwareModel: String?,
daysSinceActivation: Int?,
daysSinceLastActive: Int?) {
self.statisticsStore = statisticsStore
self.operatingSystemVersion = operatingSystemVersion
self.appVersion = appVersion
self.hardwareModel = hardwareModel
self.daysSinceActivation = daysSinceActivation
self.daysSinceLastActive = daysSinceLastActive
}

// swiftlint:disable:next cyclomatic_complexity
func buildSurveyURL(from originalURLString: String) -> URL? {
guard var components = URLComponents(string: originalURLString) else {
assertionFailure("Could not build components from survey URL")
return URL(string: originalURLString)
}

var queryItems = components.queryItems ?? []

for parameter in SurveyURLParameters.allCases {
switch parameter {
case .atb:
if let atb = statisticsStore.atb {
queryItems.append(queryItem(parameter: parameter, value: atb))
}
case .atbVariant:
if let variant = statisticsStore.variant {
queryItems.append(queryItem(parameter: parameter, value: variant))
}
case .daysSinceActivated:
if let daysSinceActivation {
queryItems.append(queryItem(parameter: parameter, value: daysSinceActivation))
}
case .macOSVersion:
queryItems.append(queryItem(parameter: parameter, value: operatingSystemVersion))
case .appVersion:
queryItems.append(queryItem(parameter: parameter, value: appVersion))
case .hardwareModel:
if let hardwareModel = hardwareModel {
queryItems.append(queryItem(parameter: parameter, value: hardwareModel))
}
case .lastDayActive:
if let daysSinceLastActive {
queryItems.append(queryItem(parameter: parameter, value: daysSinceLastActive))
}
}
}

components.queryItems = queryItems

return components.url
}

private func queryItem(parameter: SurveyURLParameters, value: String) -> URLQueryItem {
let urlAllowed: CharacterSet = .alphanumerics.union(.init(charactersIn: "-._~"))
let sanitizedValue = value.addingPercentEncoding(withAllowedCharacters: urlAllowed)
return URLQueryItem(name: parameter.rawValue, value: sanitizedValue)
}

private func queryItem(parameter: SurveyURLParameters, value: Int) -> URLQueryItem {
return URLQueryItem(name: parameter.rawValue, value: String(describing: value))
}

}
5 changes: 5 additions & 0 deletions DuckDuckGo/DBP/DBPHomeViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ final class DBPHomeViewController: NSViewController {
if !dataBrokerProtectionManager.shouldAskForInviteCode() {
attachDataBrokerContainerView()
}

if dataBrokerProtectionManager.dataManager.fetchProfile() != nil {
let dbpDateStore = DefaultWaitlistActivationDateStore(source: .dbp)
dbpDateStore.updateLastActiveDate()
}
}

private func attachDataBrokerContainerView() {
Expand Down
4 changes: 4 additions & 0 deletions DuckDuckGo/DBP/DataBrokerProtectionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public final class DataBrokerProtectionManager {
private let authenticationService: DataBrokerProtectionAuthenticationService = AuthenticationService()
private let redeemUseCase: DataBrokerProtectionRedeemUseCase
private let fakeBrokerFlag: DataBrokerDebugFlag = DataBrokerDebugFlagFakeBroker()
private let dataBrokerProtectionWaitlistDataSource: WaitlistActivationDateStore = DefaultWaitlistActivationDateStore(source: .dbp)

lazy var dataManager: DataBrokerProtectionDataManager = {
let dataManager = DataBrokerProtectionDataManager(fakeBrokerFlag: fakeBrokerFlag)
Expand Down Expand Up @@ -59,6 +60,9 @@ public final class DataBrokerProtectionManager {
extension DataBrokerProtectionManager: DataBrokerProtectionDataManagerDelegate {
public func dataBrokerProtectionDataManagerDidUpdateData() {
scheduler.startScheduler()

let dbpDateStore = DefaultWaitlistActivationDateStore(source: .dbp)
dbpDateStore.setActivationDateIfNecessary()
}

public func dataBrokerProtectionDataManagerDidDeleteData() {
Expand Down
Loading

0 comments on commit 09113e8

Please sign in to comment.