-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
28 changed files
with
928 additions
and
305 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
103 changes: 103 additions & 0 deletions
103
DuckDuckGo/Common/Surveys/HomePageRemoteMessagingRequest.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
115
DuckDuckGo/Common/Surveys/HomePageRemoteMessagingStorage.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.