Skip to content

Commit

Permalink
Remote Messaging Framework for macOS (#876)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/72649045549333/1202913520695928/f

Description:
This change adds support for RMF on macOS.
* Remaining RMF functionality previously present in iOS codebase was moved to BSK
  (most importantly RemoteMessagingProcessing protocol).
* remoteMessaging feature flag was added.
* RemoteMessagingConfigMatcherProviding protocol was added – it's implemented by client apps
  to create and return the config matcher filled with current values of matching attributes.
* Support for platform-specific user attribute matchers was added. CommonUserAttributeMatcher was added
  to handle attributes that are the supported on both iOS and macOS. Mobile and Desktop user attribute matcher
  structs were added to support platform-specific attributes (currently it's only isWidgetInstalled for iOS).
* RemoteMessagingRequest was replaced with RemoteMessagingConfigFetcher that uses Configuration Store
  and supports Etags.
* All RMF tests (and mocks) were moved out of BrowserServicesKit(Tests) into RemoteMessagingTestsUtils
  and RemoteMessagingTests (that also mandated creating EmailTestsUtils).
* RemoteMessagingStore was updated to keep a reference to the database and create contexts on demand,
  to ensure it always gets the freshest data from the persistent store.
  • Loading branch information
ayoy authored Jul 9, 2024
1 parent 0d59b3a commit bfabf45
Show file tree
Hide file tree
Showing 49 changed files with 1,947 additions and 624 deletions.
24 changes: 23 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ let package = Package(
.library(name: "Common", targets: ["Common"]),
.library(name: "TestUtils", targets: ["TestUtils"]),
.library(name: "DDGSync", targets: ["DDGSync"]),
.library(name: "BrowserServicesKitTestsUtils", targets: ["BrowserServicesKitTestsUtils"]),
.library(name: "Persistence", targets: ["Persistence"]),
.library(name: "Bookmarks", targets: ["Bookmarks"]),
.library(name: "BloomFilterWrapper", targets: ["BloomFilterWrapper"]),
Expand All @@ -26,6 +27,7 @@ let package = Package(
.library(name: "Configuration", targets: ["Configuration"]),
.library(name: "Networking", targets: ["Networking"]),
.library(name: "RemoteMessaging", targets: ["RemoteMessaging"]),
.library(name: "RemoteMessagingTestsUtils", targets: ["RemoteMessagingTestsUtils"]),
.library(name: "Navigation", targets: ["Navigation"]),
.library(name: "SyncDataProviders", targets: ["SyncDataProviders"]),
.library(name: "NetworkProtection", targets: ["NetworkProtection"]),
Expand Down Expand Up @@ -76,6 +78,12 @@ let package = Package(
.define("DEBUG", .when(configuration: .debug))
]
),
.target(
name: "BrowserServicesKitTestsUtils",
dependencies: [
"BrowserServicesKit",
]
),
.target(
name: "Persistence",
dependencies: [
Expand Down Expand Up @@ -261,9 +269,11 @@ let package = Package(
name: "RemoteMessaging",
dependencies: [
"Common",
"Configuration",
"BrowserServicesKit",
"Networking",
"Persistence",
"Subscription"
],
resources: [
.process("CoreData/RemoteMessaging.xcdatamodeld")
Expand All @@ -272,6 +282,12 @@ let package = Package(
.define("DEBUG", .when(configuration: .debug))
]
),
.target(
name: "RemoteMessagingTestsUtils",
dependencies: [
"RemoteMessaging",
]
),
.target(
name: "SyncDataProviders",
dependencies: [
Expand Down Expand Up @@ -400,7 +416,7 @@ let package = Package(
name: "BrowserServicesKitTests",
dependencies: [
"BrowserServicesKit",
"RemoteMessaging", // Move tests later (lots of test dependencies in BSK)
"BrowserServicesKitTestsUtils",
"SecureStorageTestsUtils",
"TestUtils",
"Subscription"
Expand Down Expand Up @@ -482,10 +498,16 @@ let package = Package(
.testTarget(
name: "RemoteMessagingTests",
dependencies: [
"BrowserServicesKitTestsUtils",
"RemoteMessaging",
"RemoteMessagingTestsUtils",
"TestUtils",
],
resources: [
.copy("Resources/remote-messaging-config-example.json"),
.copy("Resources/remote-messaging-config-malformed.json"),
.copy("Resources/remote-messaging-config-unsupported-items.json"),
.copy("Resources/remote-messaging-config.json"),
]
),
.testTarget(
Expand Down
25 changes: 25 additions & 0 deletions Sources/Bookmarks/BookmarkUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,31 @@ public struct BookmarkUtils {
return result.compactMap { $0[#keyPath(BookmarkEntity.title)] as? String }
}

public static func numberOfBookmarks(in context: NSManagedObjectContext) -> Int {
let request = BookmarkEntity.fetchRequest()
request.predicate = NSPredicate(
format: "%K == false AND %K == false AND (%K == NO OR %K == nil)",
#keyPath(BookmarkEntity.isFolder),
#keyPath(BookmarkEntity.isPendingDeletion),
#keyPath(BookmarkEntity.isStub), #keyPath(BookmarkEntity.isStub))
return (try? context.count(for: request)) ?? 0
}

public static func numberOfFavorites(for displayMode: FavoritesDisplayMode, in context: NSManagedObjectContext) -> Int {
guard let displayedFavoritesFolder = BookmarkUtils.fetchFavoritesFolder(withUUID: displayMode.displayedFolder.rawValue, in: context) else {
return 0
}

let request = BookmarkEntity.fetchRequest()
request.predicate = NSPredicate(format: "%K CONTAINS %@ AND %K == false AND %K == false AND (%K == NO OR %K == nil)",
#keyPath(BookmarkEntity.favoriteFolders),
displayedFavoritesFolder,
#keyPath(BookmarkEntity.isFolder),
#keyPath(BookmarkEntity.isPendingDeletion),
#keyPath(BookmarkEntity.isStub), #keyPath(BookmarkEntity.isStub))
return (try? context.count(for: request)) ?? 0
}

// MARK: Internal

@discardableResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public enum PrivacyFeature: String {
case sslCertificates
case brokenSiteReportExperiment
case toggleReports
case remoteMessaging
}

/// An abstraction to be implemented by any "subfeature" of a given `PrivacyConfiguration` feature.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// MockEmailManagerRequestDelegate.swift
//
// Copyright © 2024 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.
//

@testable import BrowserServicesKit
import Foundation

public class MockEmailManagerRequestDelegate: EmailManagerRequestDelegate {

public init(didSendMockAliasRequest: @escaping () -> Void = {}) {
self.didSendMockAliasRequest = didSendMockAliasRequest
}

public var activeTask: URLSessionTask?
public var mockAliases: [String] = []
public var waitlistTimestamp: Int = 1
public var didSendMockAliasRequest: () -> Void

// swiftlint:disable function_parameter_count
public func emailManager(_ emailManager: EmailManager, requested url: URL, method: String, headers: [String: String], parameters: [String: String]?, httpBody: Data?, timeoutInterval: TimeInterval) async throws -> Data {
switch url.absoluteString {
case EmailUrls.Url.emailAlias: return try processMockAliasRequest().get()
default: fatalError("\(#file): Unsupported URL passed to mock request delegate: \(url)")
}
}
// swiftlint:enable function_parameter_count

public var keychainAccessErrorAccessType: EmailKeychainAccessType?
public var keychainAccessError: EmailKeychainAccessError?

public func emailManagerKeychainAccessFailed(_ emailManager: EmailManager,
accessType: EmailKeychainAccessType,
error: EmailKeychainAccessError) {
keychainAccessErrorAccessType = accessType
keychainAccessError = error
}

private func processMockAliasRequest() -> Result<Data, Error> {
didSendMockAliasRequest()

if mockAliases.first != nil {
let alias = mockAliases.removeFirst()
let jsonString = "{\"address\":\"\(alias)\"}"
let data = jsonString.data(using: .utf8)!
return .success(data)
} else {
return .failure(AliasRequestError.noDataError)
}
}

}
90 changes: 90 additions & 0 deletions Sources/BrowserServicesKitTestsUtils/MockEmailManagerStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// MockEmailManagerStorage.swift
//
// Copyright © 2024 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 BrowserServicesKit
import Foundation

public class MockEmailManagerStorage: EmailManagerStorage {

public var mockError: EmailKeychainAccessError?

public var mockUsername: String?
public var mockToken: String?
public var mockAlias: String?
public var mockCohort: String?
public var mockLastUseDate: String?

public var storeTokenCallback: ((String, String, String?) -> Void)?
public var storeAliasCallback: ((String) -> Void)?
public var storeLastUseDateCallback: ((String) -> Void)?
public var deleteAliasCallback: (() -> Void)?
public var deleteAuthenticationStateCallback: (() -> Void)?
public var deleteWaitlistStateCallback: (() -> Void)?

public init() {}

public func getUsername() throws -> String? {
if let mockError = mockError { throw mockError }
return mockUsername
}

public func getToken() throws -> String? {
if let mockError = mockError { throw mockError }
return mockToken
}

public func getAlias() throws -> String? {
if let mockError = mockError { throw mockError }
return mockAlias
}

public func getCohort() throws -> String? {
if let mockError = mockError { throw mockError }
return mockCohort
}

public func getLastUseDate() throws -> String? {
if let mockError = mockError { throw mockError }
return mockLastUseDate
}

public func store(token: String, username: String, cohort: String?) throws {
storeTokenCallback?(token, username, cohort)
}

public func store(alias: String) throws {
storeAliasCallback?(alias)
}

public func store(lastUseDate: String) throws {
storeLastUseDateCallback?(lastUseDate)
}

public func deleteAlias() {
deleteAliasCallback?()
}

public func deleteAuthenticationState() {
deleteAuthenticationStateCallback?()
}

public func deleteWaitlistState() {
deleteWaitlistStateCallback?()
}

}
36 changes: 36 additions & 0 deletions Sources/BrowserServicesKitTestsUtils/MockStatisticsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// MockStatisticsStore.swift
//
// Copyright © 2024 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 BrowserServicesKit
import Foundation

public class MockStatisticsStore: StatisticsStore {

public init() {}

public var installDate: Date?
public var atb: String?
public var searchRetentionAtb: String?
public var appRetentionAtb: String?

public var hasInstallStatistics: Bool {
return atb != nil
}

public var variant: String?
}
34 changes: 34 additions & 0 deletions Sources/BrowserServicesKitTestsUtils/MockVariant.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// MockVariant.swift
//
// Copyright © 2024 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 BrowserServicesKit
import Foundation

public class MockVariant: Variant {
public var name: String
public var weight: Int
public var isIncluded: () -> Bool
public var features: [FeatureName]

public init(name: String, weight: Int, isIncluded: @escaping () -> Bool, features: [FeatureName]) {
self.name = name
self.weight = weight
self.isIncluded = isIncluded
self.features = features
}
}
Loading

0 comments on commit bfabf45

Please sign in to comment.