diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
index 12cc6cea7..2b9e11ee5 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
@@ -539,6 +539,20 @@
ReferencedContainer = "container:">
+
+
+
+
+
+
+
+
Date {
+ Calendar.current.date(byAdding: .day, value: -days, to: self)!
}
static var startOfMinuteNow: Date {
diff --git a/Sources/NetworkProtection/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift
index 7f8c312e2..10d3aa8cc 100644
--- a/Sources/NetworkProtection/PacketTunnelProvider.swift
+++ b/Sources/NetworkProtection/PacketTunnelProvider.swift
@@ -43,7 +43,6 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
case rekeyAttempt(_ step: RekeyAttemptStep)
case failureRecoveryAttempt(_ step: FailureRecoveryStep)
case serverMigrationAttempt(_ step: ServerMigrationAttemptStep)
- case malformedErrorDetected(_ error: Error)
}
public enum AttemptStep: CustomDebugStringConvertible {
@@ -693,7 +692,6 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
// Check that the error is valid and able to be re-thrown to the OS before shutting the tunnel down
if let wrappedError = wrapped(error: error) {
// Wait for the provider to complete its pixel request.
- providerEvents.fire(.malformedErrorDetected(error))
try? await Task.sleep(interval: .seconds(2))
throw wrappedError
} else {
@@ -733,7 +731,6 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
// Check that the error is valid and able to be re-thrown to the OS before shutting the tunnel down
if let wrappedError = wrapped(error: error) {
// Wait for the provider to complete its pixel request.
- providerEvents.fire(.malformedErrorDetected(error))
try? await Task.sleep(interval: .seconds(2))
throw wrappedError
} else {
diff --git a/Sources/PrivacyStats/Logger+PrivacyStats.swift b/Sources/PrivacyStats/Logger+PrivacyStats.swift
new file mode 100644
index 000000000..e8649a6af
--- /dev/null
+++ b/Sources/PrivacyStats/Logger+PrivacyStats.swift
@@ -0,0 +1,24 @@
+//
+// Logger+PrivacyStats.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 Foundation
+import os.log
+
+public extension Logger {
+ static var privacyStats = { Logger(subsystem: "Privacy Stats", category: "") }()
+}
diff --git a/Sources/PrivacyStats/PrivacyStats.swift b/Sources/PrivacyStats/PrivacyStats.swift
new file mode 100644
index 000000000..c298f60fc
--- /dev/null
+++ b/Sources/PrivacyStats/PrivacyStats.swift
@@ -0,0 +1,249 @@
+//
+// PrivacyStats.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 Combine
+import Common
+import CoreData
+import Foundation
+import os.log
+import Persistence
+import TrackerRadarKit
+
+/**
+ * Errors that may be reported by `PrivacyStats`.
+ */
+public enum PrivacyStatsError: CustomNSError {
+ case failedToFetchPrivacyStatsSummary(Error)
+ case failedToStorePrivacyStats(Error)
+ case failedToLoadCurrentPrivacyStats(Error)
+
+ public static let errorDomain: String = "PrivacyStatsError"
+
+ public var errorCode: Int {
+ switch self {
+ case .failedToFetchPrivacyStatsSummary:
+ return 1
+ case .failedToStorePrivacyStats:
+ return 2
+ case .failedToLoadCurrentPrivacyStats:
+ return 3
+ }
+ }
+
+ public var underlyingError: Error {
+ switch self {
+ case .failedToFetchPrivacyStatsSummary(let error),
+ .failedToStorePrivacyStats(let error),
+ .failedToLoadCurrentPrivacyStats(let error):
+ return error
+ }
+ }
+}
+
+/**
+ * This protocol describes database provider consumed by `PrivacyStats`.
+ */
+public protocol PrivacyStatsDatabaseProviding {
+ func initializeDatabase() -> CoreDataDatabase
+}
+
+/**
+ * This protocol describes `PrivacyStats` interface.
+ */
+public protocol PrivacyStatsCollecting {
+
+ /**
+ * Record a tracker for a given `companyName`.
+ *
+ * `PrivacyStats` implementation calls the `CurrentPack` actor under the hood,
+ * and as such it can safely be called on multiple threads concurrently.
+ */
+ func recordBlockedTracker(_ name: String) async
+
+ /**
+ * Publisher emitting values whenever updated privacy stats were persisted to disk.
+ */
+ var statsUpdatePublisher: AnyPublisher { get }
+
+ /**
+ * This function fetches privacy stats in a dictionary format
+ * with keys being company names and values being total number
+ * of tracking attempts blocked in past 7 days.
+ */
+ func fetchPrivacyStats() async -> [String: Int64]
+
+ /**
+ * This function clears all blocked tracker stats from the database.
+ */
+ func clearPrivacyStats() async
+
+ /**
+ * This function saves all pending changes to the persistent storage.
+ *
+ * It should only be used in response to app termination because otherwise
+ * the `PrivacyStats` object schedules persisting internally.
+ */
+ func handleAppTermination() async
+}
+
+public final class PrivacyStats: PrivacyStatsCollecting {
+
+ public static let bundle = Bundle.module
+
+ public let statsUpdatePublisher: AnyPublisher
+
+ private let db: CoreDataDatabase
+ private let context: NSManagedObjectContext
+ private let currentPack: CurrentPack
+ private let statsUpdateSubject = PassthroughSubject()
+ private var cancellables: Set = []
+
+ private let errorEvents: EventMapping?
+
+ public init(databaseProvider: PrivacyStatsDatabaseProviding, errorEvents: EventMapping? = nil) {
+ self.db = databaseProvider.initializeDatabase()
+ self.context = db.makeContext(concurrencyType: .privateQueueConcurrencyType, name: "PrivacyStats")
+ self.errorEvents = errorEvents
+ self.currentPack = .init(pack: Self.initializeCurrentPack(in: context, errorEvents: errorEvents))
+ statsUpdatePublisher = statsUpdateSubject.eraseToAnyPublisher()
+
+ currentPack.commitChangesPublisher
+ .sink { [weak self] pack in
+ Task {
+ await self?.commitChanges(pack)
+ }
+ }
+ .store(in: &cancellables)
+ }
+
+ public func recordBlockedTracker(_ companyName: String) async {
+ await currentPack.recordBlockedTracker(companyName)
+ }
+
+ public func fetchPrivacyStats() async -> [String: Int64] {
+ return await withCheckedContinuation { continuation in
+ context.perform { [weak self] in
+ guard let self else {
+ continuation.resume(returning: [:])
+ return
+ }
+ do {
+ let stats = try PrivacyStatsUtils.load7DayStats(in: context)
+ continuation.resume(returning: stats)
+ } catch {
+ errorEvents?.fire(.failedToFetchPrivacyStatsSummary(error))
+ continuation.resume(returning: [:])
+ }
+ }
+ }
+ }
+
+ public func clearPrivacyStats() async {
+ await withCheckedContinuation { continuation in
+ context.perform { [weak self] in
+ guard let self else {
+ continuation.resume()
+ return
+ }
+ do {
+ try PrivacyStatsUtils.deleteAllStats(in: context)
+ Logger.privacyStats.debug("Deleted outdated entries")
+ } catch {
+ Logger.privacyStats.error("Save error: \(error)")
+ errorEvents?.fire(.failedToFetchPrivacyStatsSummary(error))
+ }
+ continuation.resume()
+ }
+ }
+ await currentPack.resetPack()
+ statsUpdateSubject.send()
+ }
+
+ public func handleAppTermination() async {
+ await commitChanges(currentPack.pack)
+ }
+
+ // MARK: - Private
+
+ private func commitChanges(_ pack: PrivacyStatsPack) async {
+ await withCheckedContinuation { continuation in
+ context.perform { [weak self] in
+ guard let self else {
+ continuation.resume()
+ return
+ }
+
+ // Check if the pack we're currently storing is from a previous day.
+ let isCurrentDayPack = pack.timestamp == Date.currentPrivacyStatsPackTimestamp
+
+ do {
+ let statsObjects = try PrivacyStatsUtils.fetchOrInsertCurrentStats(for: Set(pack.trackers.keys), in: context)
+ statsObjects.forEach { stats in
+ if let count = pack.trackers[stats.companyName] {
+ stats.count = count
+ }
+ }
+
+ guard context.hasChanges else {
+ continuation.resume()
+ return
+ }
+
+ try context.save()
+ Logger.privacyStats.debug("Saved stats \(pack.timestamp) \(pack.trackers)")
+
+ if isCurrentDayPack {
+ // Only emit update event when saving current-day pack. For previous-day pack,
+ // a follow-up commit event will come and we'll emit the update then.
+ statsUpdateSubject.send()
+ } else {
+ // When storing a pack from a previous day, we may have outdated packs, so delete them as needed.
+ try PrivacyStatsUtils.deleteOutdatedPacks(in: context)
+ }
+ } catch {
+ Logger.privacyStats.error("Save error: \(error)")
+ errorEvents?.fire(.failedToStorePrivacyStats(error))
+ }
+ continuation.resume()
+ }
+ }
+ }
+
+ /**
+ * This function is only called in the initializer. It performs a blocking call to the database
+ * to spare us the hassle of declaring the initializer async or spawning tasks from within the
+ * initializer without being able to await them, thus making testing trickier.
+ */
+ private static func initializeCurrentPack(in context: NSManagedObjectContext, errorEvents: EventMapping?) -> PrivacyStatsPack {
+ var pack: PrivacyStatsPack?
+ context.performAndWait {
+ let timestamp = Date.currentPrivacyStatsPackTimestamp
+ do {
+ let currentDayStats = try PrivacyStatsUtils.loadCurrentDayStats(in: context)
+ Logger.privacyStats.debug("Loaded stats \(timestamp) \(currentDayStats)")
+ pack = PrivacyStatsPack(timestamp: timestamp, trackers: currentDayStats)
+
+ try PrivacyStatsUtils.deleteOutdatedPacks(in: context)
+ } catch {
+ Logger.privacyStats.error("Failed to load current stats: \(error)")
+ errorEvents?.fire(.failedToLoadCurrentPrivacyStats(error))
+ }
+ }
+ return pack ?? PrivacyStatsPack(timestamp: Date.currentPrivacyStatsPackTimestamp)
+ }
+}
diff --git a/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/.xccurrentversion b/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/.xccurrentversion
new file mode 100644
index 000000000..1a19d1654
--- /dev/null
+++ b/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/.xccurrentversion
@@ -0,0 +1,8 @@
+
+
+
+
+ _XCCurrentVersionName
+ PrivacyStats.xcdatamodel
+
+
diff --git a/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/PrivacyStats.xcdatamodel/contents b/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/PrivacyStats.xcdatamodel/contents
new file mode 100644
index 000000000..39798857a
--- /dev/null
+++ b/Sources/PrivacyStats/PrivacyStats.xcdatamodeld/PrivacyStats.xcdatamodel/contents
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Sources/PrivacyStats/internal/CurrentPack.swift b/Sources/PrivacyStats/internal/CurrentPack.swift
new file mode 100644
index 000000000..fffc6b090
--- /dev/null
+++ b/Sources/PrivacyStats/internal/CurrentPack.swift
@@ -0,0 +1,116 @@
+//
+// CurrentPack.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 Combine
+import Foundation
+import os.log
+
+/**
+ * This actor provides thread-safe access to an instance of `PrivacyStatsPack`.
+ *
+ * It's used by `PrivacyStats` class to record blocked trackers that can possibly
+ * come from multiple open tabs (web views) at the same time.
+ */
+actor CurrentPack {
+ /**
+ * Current stats pack.
+ */
+ private(set) var pack: PrivacyStatsPack
+
+ /**
+ * Publisher that fires events whenever tracker stats are ready to be persisted to disk.
+ *
+ * This happens after recording new blocked tracker, when no new tracker has been recorded for 1s.
+ */
+ nonisolated private(set) lazy var commitChangesPublisher: AnyPublisher = commitChangesSubject.eraseToAnyPublisher()
+
+ nonisolated private let commitChangesSubject = PassthroughSubject()
+ private var commitTask: Task?
+ private var commitDebounce: UInt64
+
+ /// The `commitDebounce` parameter should only be modified in unit tests.
+ init(pack: PrivacyStatsPack, commitDebounce: UInt64 = 1_000_000_000) {
+ self.pack = pack
+ self.commitDebounce = commitDebounce
+ }
+
+ deinit {
+ commitTask?.cancel()
+ }
+
+ /**
+ * This function is used when clearing app data, to clear any stats cached in memory.
+ *
+ * It sets a new empty pack with the current timestamp.
+ */
+ func resetPack() {
+ resetStats(andSet: Date.currentPrivacyStatsPackTimestamp)
+ }
+
+ /**
+ * This function increments trackers count for a given company name.
+ *
+ * Updates are kept in memory and scheduled for saving to persistent storage with 1s debounce.
+ * This function also detects when the current pack becomes outdated (which happens
+ * when current timestamp's day becomes greater than pack's timestamp's day), in which
+ * case current pack is scheduled for persisting on disk and a new empty pack is
+ * created for the new timestamp.
+ */
+ func recordBlockedTracker(_ companyName: String) {
+
+ let currentTimestamp = Date.currentPrivacyStatsPackTimestamp
+ if currentTimestamp != pack.timestamp {
+ Logger.privacyStats.debug("New timestamp detected, storing trackers state and creating new pack")
+ notifyChanges(for: pack, immediately: true)
+ resetStats(andSet: currentTimestamp)
+ }
+
+ let count = pack.trackers[companyName] ?? 0
+ pack.trackers[companyName] = count + 1
+
+ notifyChanges(for: pack, immediately: false)
+ }
+
+ private func notifyChanges(for pack: PrivacyStatsPack, immediately shouldPublishImmediately: Bool) {
+ commitTask?.cancel()
+
+ if shouldPublishImmediately {
+
+ commitChangesSubject.send(pack)
+
+ } else {
+
+ commitTask = Task {
+ do {
+ // Note that this doesn't always sleep for the full debounce time, but the sleep is interrupted
+ // as soon as the task gets cancelled.
+ try await Task.sleep(nanoseconds: commitDebounce)
+
+ Logger.privacyStats.debug("Storing trackers state")
+ commitChangesSubject.send(pack)
+ } catch {
+ // Commit task got cancelled
+ }
+ }
+ }
+ }
+
+ private func resetStats(andSet newTimestamp: Date) {
+ pack = PrivacyStatsPack(timestamp: newTimestamp, trackers: [:])
+ }
+}
diff --git a/Sources/PrivacyStats/internal/DailyBlockedTrackersEntity.swift b/Sources/PrivacyStats/internal/DailyBlockedTrackersEntity.swift
new file mode 100644
index 000000000..728a5943c
--- /dev/null
+++ b/Sources/PrivacyStats/internal/DailyBlockedTrackersEntity.swift
@@ -0,0 +1,55 @@
+//
+// DailyBlockedTrackersEntity.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 Common
+import CoreData
+
+@objc(DailyBlockedTrackersEntity)
+final class DailyBlockedTrackersEntity: NSManagedObject {
+ enum Const {
+ static let entityName = "DailyBlockedTrackersEntity"
+ }
+
+ @nonobjc class func fetchRequest() -> NSFetchRequest {
+ NSFetchRequest(entityName: Const.entityName)
+ }
+
+ class func entity(in context: NSManagedObjectContext) -> NSEntityDescription {
+ NSEntityDescription.entity(forEntityName: Const.entityName, in: context)!
+ }
+
+ @NSManaged var companyName: String
+ @NSManaged var count: Int64
+ @NSManaged var timestamp: Date
+
+ private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) {
+ super.init(entity: entity, insertInto: context)
+ }
+
+ private convenience init(context moc: NSManagedObjectContext) {
+ self.init(entity: DailyBlockedTrackersEntity.entity(in: moc), insertInto: moc)
+ }
+
+ static func make(timestamp: Date = Date(), companyName: String, count: Int64 = 0, context: NSManagedObjectContext) -> DailyBlockedTrackersEntity {
+ let object = DailyBlockedTrackersEntity(context: context)
+ object.timestamp = timestamp.privacyStatsPackTimestamp
+ object.companyName = companyName
+ object.count = count
+ return object
+ }
+}
diff --git a/Sources/PrivacyStats/internal/Date+PrivacyStats.swift b/Sources/PrivacyStats/internal/Date+PrivacyStats.swift
new file mode 100644
index 000000000..f56072d75
--- /dev/null
+++ b/Sources/PrivacyStats/internal/Date+PrivacyStats.swift
@@ -0,0 +1,51 @@
+//
+// Date+PrivacyStats.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 Common
+import Foundation
+
+extension Date {
+
+ /**
+ * Returns privacy stats pack timestamp for the current date.
+ *
+ * See `privacyStatsPackTimestamp`.
+ */
+ static var currentPrivacyStatsPackTimestamp: Date {
+ Date().privacyStatsPackTimestamp
+ }
+
+ /**
+ * Returns a valid timestamp for `DailyBlockedTrackersEntity` instance matching the sender.
+ *
+ * Blocked trackers are packed by day so the timestap of the pack must be the exact start of a day.
+ */
+ var privacyStatsPackTimestamp: Date {
+ startOfDay
+ }
+
+ /**
+ * Returns the oldest valid timestamp for `DailyBlockedTrackersEntity` instance matching the sender.
+ *
+ * Privacy Stats only keeps track of 7 days worth of tracking history, so the oldest timestamp is
+ * beginning of the day 6 days ago.
+ */
+ var privacyStatsOldestPackTimestamp: Date {
+ privacyStatsPackTimestamp.daysAgo(6)
+ }
+}
diff --git a/Sources/PrivacyStats/internal/PrivacyStatsPack.swift b/Sources/PrivacyStats/internal/PrivacyStatsPack.swift
new file mode 100644
index 000000000..3c4c8a04b
--- /dev/null
+++ b/Sources/PrivacyStats/internal/PrivacyStatsPack.swift
@@ -0,0 +1,32 @@
+//
+// PrivacyStatsPack.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 Foundation
+
+/**
+ * This struct keeps track of the summary of blocked trackers for a single unit of time (1 day).
+ */
+struct PrivacyStatsPack: Equatable {
+ let timestamp: Date
+ var trackers: [String: Int64]
+
+ init(timestamp: Date, trackers: [String: Int64] = [:]) {
+ self.timestamp = timestamp
+ self.trackers = trackers
+ }
+}
diff --git a/Sources/PrivacyStats/internal/PrivacyStatsUtils.swift b/Sources/PrivacyStats/internal/PrivacyStatsUtils.swift
new file mode 100644
index 000000000..33b93d869
--- /dev/null
+++ b/Sources/PrivacyStats/internal/PrivacyStatsUtils.swift
@@ -0,0 +1,123 @@
+//
+// PrivacyStatsUtils.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 Common
+import CoreData
+import Foundation
+import Persistence
+
+final class PrivacyStatsUtils {
+
+ /**
+ * Returns objects corresponding to current stats for companies specified by `companyNames`.
+ *
+ * If an object doesn't exist (no trackers for a given company were reported on a given day)
+ * then a new object for that company is inserted into the context and returned.
+ * If a user opens the app for the first time on a given day, the database will not contain
+ * any records for that day and this function will only insert new objects.
+ *
+ * > Note: `current stats` refer to stats objects that are active on a given day, i.e. their
+ * timestamp's day matches current day.
+ */
+ static func fetchOrInsertCurrentStats(for companyNames: Set, in context: NSManagedObjectContext) throws -> [DailyBlockedTrackersEntity] {
+ let timestamp = Date.currentPrivacyStatsPackTimestamp
+
+ let request = DailyBlockedTrackersEntity.fetchRequest()
+ request.predicate = NSPredicate(format: "%K == %@ AND %K in %@",
+ #keyPath(DailyBlockedTrackersEntity.timestamp), timestamp as NSDate,
+ #keyPath(DailyBlockedTrackersEntity.companyName), companyNames)
+ request.returnsObjectsAsFaults = false
+
+ var statsObjects = try context.fetch(request)
+ let missingCompanyNames = companyNames.subtracting(statsObjects.map(\.companyName))
+
+ for companyName in missingCompanyNames {
+ statsObjects.append(DailyBlockedTrackersEntity.make(timestamp: timestamp, companyName: companyName, context: context))
+ }
+ return statsObjects
+ }
+
+ /**
+ * Returns a dictionary representation of blocked trackers counts grouped by company name for the current day.
+ */
+ static func loadCurrentDayStats(in context: NSManagedObjectContext) throws -> [String: Int64] {
+ let startDate = Date.currentPrivacyStatsPackTimestamp
+ return try loadBlockedTrackersStats(since: startDate, in: context)
+ }
+
+ /**
+ * Returns a dictionary representation of blocked trackers counts grouped by company name for past 7 days.
+ */
+ static func load7DayStats(in context: NSManagedObjectContext) throws -> [String: Int64] {
+ let startDate = Date().privacyStatsOldestPackTimestamp
+ return try loadBlockedTrackersStats(since: startDate, in: context)
+ }
+
+ private static func loadBlockedTrackersStats(since startDate: Date, in context: NSManagedObjectContext) throws -> [String: Int64] {
+ let request = NSFetchRequest(entityName: DailyBlockedTrackersEntity.Const.entityName)
+ request.predicate = NSPredicate(format: "%K >= %@", #keyPath(DailyBlockedTrackersEntity.timestamp), startDate as NSDate)
+
+ let companyNameKey = #keyPath(DailyBlockedTrackersEntity.companyName)
+
+ // Expression description for the sum of count
+ let countExpression = NSExpression(forKeyPath: #keyPath(DailyBlockedTrackersEntity.count))
+ let sumExpression = NSExpression(forFunction: "sum:", arguments: [countExpression])
+
+ let sumExpressionDescription = NSExpressionDescription()
+ sumExpressionDescription.name = "totalCount"
+ sumExpressionDescription.expression = sumExpression
+ sumExpressionDescription.expressionResultType = .integer64AttributeType
+
+ request.propertiesToGroupBy = [companyNameKey]
+ request.propertiesToFetch = [companyNameKey, sumExpressionDescription]
+ request.resultType = .dictionaryResultType
+
+ let results = (try context.fetch(request) as? [[String: Any]]) ?? []
+
+ let groupedResults = results.reduce(into: [String: Int64]()) { partialResult, result in
+ if let companyName = result[companyNameKey] as? String, let totalCount = result["totalCount"] as? Int64, totalCount > 0 {
+ partialResult[companyName] = totalCount
+ }
+ }
+
+ return groupedResults
+ }
+
+ /**
+ * Deletes stats older than 7 days for all companies.
+ */
+ static func deleteOutdatedPacks(in context: NSManagedObjectContext) throws {
+ let oldestValidTimestamp = Date().privacyStatsOldestPackTimestamp
+
+ let fetchRequest = NSFetchRequest(entityName: DailyBlockedTrackersEntity.Const.entityName)
+ fetchRequest.predicate = NSPredicate(format: "%K < %@", #keyPath(DailyBlockedTrackersEntity.timestamp), oldestValidTimestamp as NSDate)
+ let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
+
+ try context.execute(deleteRequest)
+ context.reset()
+ }
+
+ /**
+ * Deletes all stats entries in the database.
+ */
+ static func deleteAllStats(in context: NSManagedObjectContext) throws {
+ let deleteRequest = NSBatchDeleteRequest(fetchRequest: DailyBlockedTrackersEntity.fetchRequest())
+ try context.execute(deleteRequest)
+ context.reset()
+ }
+}
diff --git a/Sources/RemoteMessaging/Mappers/DefaultRemoteMessagingSurveyURLBuilder.swift b/Sources/RemoteMessaging/Mappers/DefaultRemoteMessagingSurveyURLBuilder.swift
index f8c907364..4a96c9d38 100644
--- a/Sources/RemoteMessaging/Mappers/DefaultRemoteMessagingSurveyURLBuilder.swift
+++ b/Sources/RemoteMessaging/Mappers/DefaultRemoteMessagingSurveyURLBuilder.swift
@@ -31,11 +31,16 @@ public struct DefaultRemoteMessagingSurveyURLBuilder: RemoteMessagingSurveyActio
private let statisticsStore: StatisticsStore
private let vpnActivationDateStore: VPNActivationDateProviding
private let subscription: PrivacyProSubscription?
+ private let localeIdentifier: String
- public init(statisticsStore: StatisticsStore, vpnActivationDateStore: VPNActivationDateProviding, subscription: PrivacyProSubscription?) {
+ public init(statisticsStore: StatisticsStore,
+ vpnActivationDateStore: VPNActivationDateProviding,
+ subscription: PrivacyProSubscription?,
+ localeIdentifier: String = Locale.current.identifier) {
self.statisticsStore = statisticsStore
self.vpnActivationDateStore = vpnActivationDateStore
self.subscription = subscription
+ self.localeIdentifier = localeIdentifier
}
// swiftlint:disable:next cyclomatic_complexity
@@ -72,6 +77,9 @@ public struct DefaultRemoteMessagingSurveyURLBuilder: RemoteMessagingSurveyActio
let daysSinceInstall = Calendar.current.numberOfDaysBetween(installDate, and: Date()) {
queryItems.append(URLQueryItem(name: parameter.rawValue, value: String(describing: daysSinceInstall)))
}
+ case .locale:
+ let formattedLocale = LocaleMatchingAttribute.localeIdentifierAsJsonFormat(localeIdentifier)
+ queryItems.append(URLQueryItem(name: parameter.rawValue, value: formattedLocale))
case .privacyProStatus:
if let privacyProStatusSurveyParameter = subscription?.privacyProStatusSurveyParameter {
queryItems.append(URLQueryItem(name: parameter.rawValue, value: privacyProStatusSurveyParameter))
diff --git a/Sources/RemoteMessaging/Mappers/RemoteMessagingSurveyActionMapping.swift b/Sources/RemoteMessaging/Mappers/RemoteMessagingSurveyActionMapping.swift
index 668b03e1c..a927e1c47 100644
--- a/Sources/RemoteMessaging/Mappers/RemoteMessagingSurveyActionMapping.swift
+++ b/Sources/RemoteMessaging/Mappers/RemoteMessagingSurveyActionMapping.swift
@@ -24,6 +24,7 @@ public enum RemoteMessagingSurveyActionParameter: String, CaseIterable {
case atbVariant = "var"
case daysInstalled = "delta"
case hardwareModel = "mo"
+ case locale = "locale"
case osVersion = "osv"
case privacyProStatus = "ppro_status"
case privacyProPlatform = "ppro_platform"
diff --git a/Tests/PrivacyStatsTests/CurrentPackTests.swift b/Tests/PrivacyStatsTests/CurrentPackTests.swift
new file mode 100644
index 000000000..f22ed322d
--- /dev/null
+++ b/Tests/PrivacyStatsTests/CurrentPackTests.swift
@@ -0,0 +1,121 @@
+//
+// CurrentPackTests.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 Combine
+import XCTest
+@testable import PrivacyStats
+
+final class CurrentPackTests: XCTestCase {
+ var currentPack: CurrentPack!
+
+ override func setUp() async throws {
+ currentPack = CurrentPack(pack: .init(timestamp: Date.currentPrivacyStatsPackTimestamp), commitDebounce: 10_000_000)
+ }
+
+ func testThatRecordBlockedTrackerUpdatesThePack() async {
+ await currentPack.recordBlockedTracker("A")
+ let companyA = await currentPack.pack.trackers["A"]
+ XCTAssertEqual(companyA, 1)
+ }
+
+ func testThatRecordBlockedTrackerTriggersCommitChangesEvent() async throws {
+ let packs = try await waitForCommitChangesEvents(for: 100_000_000) {
+ await currentPack.recordBlockedTracker("A")
+ }
+
+ let companyA = await currentPack.pack.trackers["A"]
+ XCTAssertEqual(companyA, 1)
+ XCTAssertEqual(packs.first?.trackers["A"], 1)
+ }
+
+ func testThatMultipleCallsToRecordBlockedTrackerOnlyTriggerOneCommitChangesEvent() async throws {
+ let packs = try await waitForCommitChangesEvents(for: 1000_000_000) {
+ await currentPack.recordBlockedTracker("A")
+ await currentPack.recordBlockedTracker("A")
+ await currentPack.recordBlockedTracker("A")
+ await currentPack.recordBlockedTracker("A")
+ await currentPack.recordBlockedTracker("A")
+ }
+
+ XCTAssertEqual(packs.count, 1)
+ XCTAssertEqual(packs.first?.trackers["A"], 5)
+ }
+
+ func testThatRecordBlockedTrackerCalledConcurrentlyForTheSameCompanyStoresAllCalls() async {
+ await withTaskGroup(of: Void.self) { group in
+ (0..<1000).forEach { _ in
+ group.addTask {
+ await self.currentPack.recordBlockedTracker("A")
+ }
+ }
+ }
+ let companyA = await currentPack.pack.trackers["A"]
+ XCTAssertEqual(companyA, 1000)
+ }
+
+ func testWhenCurrentPackIsOldThenRecordBlockedTrackerSendsCommitEventAndCreatesNewPack() async throws {
+ let oldTimestamp = Date.currentPrivacyStatsPackTimestamp.daysAgo(1)
+ let pack = PrivacyStatsPack(
+ timestamp: oldTimestamp,
+ trackers: ["A": 100, "B": 50, "C": 400]
+ )
+ currentPack = CurrentPack(pack: pack, commitDebounce: 10_000_000)
+
+ let packs = try await waitForCommitChangesEvents(for: 100_000_000) {
+ await currentPack.recordBlockedTracker("A")
+ }
+
+ XCTAssertEqual(packs.count, 2)
+ let oldPack = try XCTUnwrap(packs.first)
+ XCTAssertEqual(oldPack, pack)
+ let newPack = try XCTUnwrap(packs.last)
+ XCTAssertEqual(newPack, PrivacyStatsPack(timestamp: Date.currentPrivacyStatsPackTimestamp, trackers: ["A": 1]))
+ }
+
+ func testThatResetPackClearsAllRecordedTrackersAndSetsCurrentTimestamp() async {
+ let oldTimestamp = Date.currentPrivacyStatsPackTimestamp.daysAgo(1)
+ let pack = PrivacyStatsPack(
+ timestamp: oldTimestamp,
+ trackers: ["A": 100, "B": 50, "C": 400]
+ )
+ currentPack = CurrentPack(pack: pack, commitDebounce: 10_000_000)
+
+ await currentPack.resetPack()
+
+ let packAfterReset = await currentPack.pack
+ XCTAssertEqual(packAfterReset, PrivacyStatsPack(timestamp: Date.currentPrivacyStatsPackTimestamp, trackers: [:]))
+ }
+
+ // MARK: - Helpers
+
+ /**
+ * Sets up Combine subscription, then calls the provided block and then waits
+ * for the specific time before cancelling the subscription.
+ * Returns an array of values passed in the published events.
+ */
+ func waitForCommitChangesEvents(for nanoseconds: UInt64, _ block: () async -> Void) async throws -> [PrivacyStatsPack] {
+ var packs: [PrivacyStatsPack] = []
+ let cancellable = currentPack.commitChangesPublisher.sink { packs.append($0) }
+
+ await block()
+
+ try await Task.sleep(nanoseconds: nanoseconds)
+ cancellable.cancel()
+ return packs
+ }
+}
diff --git a/Tests/PrivacyStatsTests/PrivacyStatsTests.swift b/Tests/PrivacyStatsTests/PrivacyStatsTests.swift
new file mode 100644
index 000000000..fa05d8178
--- /dev/null
+++ b/Tests/PrivacyStatsTests/PrivacyStatsTests.swift
@@ -0,0 +1,317 @@
+//
+// PrivacyStatsTests.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 Combine
+import Persistence
+import TrackerRadarKit
+import XCTest
+@testable import PrivacyStats
+
+final class PrivacyStatsTests: XCTestCase {
+ var databaseProvider: TestPrivacyStatsDatabaseProvider!
+ var privacyStats: PrivacyStats!
+
+ override func setUp() async throws {
+ databaseProvider = TestPrivacyStatsDatabaseProvider(databaseName: type(of: self).description())
+ privacyStats = PrivacyStats(databaseProvider: databaseProvider)
+ }
+
+ override func tearDown() async throws {
+ databaseProvider.tearDownDatabase()
+ }
+
+ // MARK: - initializer
+
+ func testThatOutdatedTrackerStatsAreDeletedUponInitialization() async throws {
+ try databaseProvider.addObjects { context in
+ let date = Date()
+
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 1, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 2, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "A", count: 7, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "A", count: 100, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(8), companyName: "A", count: 100, context: context)
+ ]
+ }
+
+ // recreate database provider with existing location so that the existing database is persisted in the initializer
+ databaseProvider = TestPrivacyStatsDatabaseProvider(databaseName: type(of: self).description(), location: databaseProvider.location)
+ privacyStats = PrivacyStats(databaseProvider: databaseProvider)
+
+ let stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, ["A": 10])
+
+ let context = databaseProvider.database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+ context.performAndWait {
+ do {
+ let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest())
+ XCTAssertEqual(Set(allObjects.map(\.count)), [1, 2, 7])
+ } catch {
+ XCTFail("Context fetch should not fail")
+ }
+ }
+ }
+
+ // MARK: - fetchPrivacyStats
+
+ func testThatPrivacyStatsAreFetched() async throws {
+ let stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, [:])
+ }
+
+ func testThatFetchPrivacyStatsReturnsAllCompanies() async throws {
+ try databaseProvider.addObjects { context in
+ [
+ DailyBlockedTrackersEntity.make(companyName: "A", count: 10, context: context),
+ DailyBlockedTrackersEntity.make(companyName: "B", count: 5, context: context),
+ DailyBlockedTrackersEntity.make(companyName: "C", count: 13, context: context),
+ DailyBlockedTrackersEntity.make(companyName: "D", count: 42, context: context)
+ ]
+ }
+
+ let stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, ["A": 10, "B": 5, "C": 13, "D": 42])
+ }
+
+ func testThatFetchPrivacyStatsReturnsSumOfCompanyEntriesForPast7Days() async throws {
+ try databaseProvider.addObjects { context in
+ let date = Date()
+
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 1, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 2, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(2), companyName: "A", count: 3, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(3), companyName: "A", count: 4, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(4), companyName: "A", count: 5, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(5), companyName: "A", count: 6, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "A", count: 7, context: context)
+ ]
+ }
+
+ let stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, ["A": 28])
+ }
+
+ func testThatFetchPrivacyStatsDiscardsEntriesOlderThan7Days() async throws {
+ try databaseProvider.addObjects { context in
+ let date = Date()
+
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 1, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 2, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "A", count: 10, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(10), companyName: "A", count: 10, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(20), companyName: "A", count: 10, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(500), companyName: "A", count: 10, context: context),
+ ]
+ }
+
+ let stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, ["A": 3])
+ }
+
+ // MARK: - recordBlockedTracker
+
+ func testThatCallingRecordBlockedTrackerCausesDatabaseSaveAfterDelay() async throws {
+ await privacyStats.recordBlockedTracker("A")
+
+ var stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, [:])
+
+ try await Task.sleep(nanoseconds: 1_500_000_000)
+
+ stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, ["A": 1])
+ }
+
+ func testThatStatsUpdatePublisherIsCalledAfterDatabaseSave() async throws {
+ await privacyStats.recordBlockedTracker("A")
+
+ await waitForStatsUpdateEvent()
+
+ var stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, ["A": 1])
+
+ await privacyStats.recordBlockedTracker("B")
+
+ await waitForStatsUpdateEvent()
+
+ stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, ["A": 1, "B": 1])
+ }
+
+ func testWhenMultipleTrackersAreReportedInQuickSuccessionThenOnlyOneStatsUpdateEventIsReported() async throws {
+ await withTaskGroup(of: Void.self) { group in
+ (0..<5).forEach { _ in
+ group.addTask {
+ await self.privacyStats.recordBlockedTracker("A")
+ }
+ }
+ (0..<10).forEach { _ in
+ group.addTask {
+ await self.privacyStats.recordBlockedTracker("B")
+ }
+ }
+ (0..<3).forEach { _ in
+ group.addTask {
+ await self.privacyStats.recordBlockedTracker("C")
+ }
+ }
+ }
+
+ // We have limited testing possibilities here, so let's just await the first stats update event
+ // and verify that all trackers are reported by privacy stats.
+ await waitForStatsUpdateEvent()
+
+ let stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, ["A": 5, "B": 10, "C": 3])
+ }
+
+ func testThatCallingRecordBlockedTrackerWithNextDayTimestampCausesDeletingOldEntriesFromDatabase() async throws {
+ try databaseProvider.addObjects { context in
+ let date = Date()
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 2, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "A", count: 100, context: context),
+ ]
+ }
+
+ // recreate database provider with existing location so that the existing database is persisted in the initializer
+ databaseProvider = TestPrivacyStatsDatabaseProvider(databaseName: type(of: self).description(), location: databaseProvider.location)
+ privacyStats = PrivacyStats(databaseProvider: databaseProvider)
+
+ await privacyStats.recordBlockedTracker("A")
+
+ // No waiting here because the first commit event will be sent immediately from the actor when pack's timestamp changes.
+ // We aren't testing the debounced commit in this test case.
+
+ var stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, ["A": 2])
+
+ let context = databaseProvider.database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+ context.performAndWait {
+ do {
+ let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest())
+ XCTAssertEqual(Set(allObjects.map(\.count)), [2])
+ } catch {
+ XCTFail("Context fetch should not fail")
+ }
+ }
+
+ await waitForStatsUpdateEvent()
+ stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, ["A": 3])
+ }
+
+ // MARK: - clearPrivacyStats
+
+ func testThatClearPrivacyStatsTriggersUpdatesPublisher() async throws {
+ try await waitForStatsUpdateEvents(for: 1, count: 1) {
+ await privacyStats.clearPrivacyStats()
+ }
+ }
+
+ func testWhenClearPrivacyStatsIsCalledThenFetchPrivacyStatsIsEmpty() async throws {
+ try databaseProvider.addObjects { context in
+ let date = Date()
+
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 1, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 2, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "A", count: 10, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(10), companyName: "A", count: 10, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(20), companyName: "A", count: 10, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(500), companyName: "A", count: 10, context: context),
+ ]
+ }
+
+ var stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertFalse(stats.isEmpty)
+
+ await privacyStats.clearPrivacyStats()
+
+ stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertTrue(stats.isEmpty)
+
+ let context = databaseProvider.database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+ context.performAndWait {
+ do {
+ let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest())
+ XCTAssertTrue(allObjects.isEmpty)
+ } catch {
+ XCTFail("fetch failed: \(error)")
+ }
+ }
+ }
+
+ // MARK: - handleAppTermination
+
+ func testThatHandleAppTerminationSavesCurrentPack() async throws {
+ let context = databaseProvider.database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest())
+ XCTAssertTrue(allObjects.isEmpty)
+ } catch {
+ XCTFail("fetch failed: \(error)")
+ }
+ }
+ await privacyStats.recordBlockedTracker("A")
+ await privacyStats.handleAppTermination()
+
+ context.performAndWait {
+ do {
+ let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest())
+ XCTAssertEqual(allObjects.count, 1)
+ } catch {
+ XCTFail("fetch failed: \(error)")
+ }
+ }
+
+ await waitForStatsUpdateEvent()
+ let stats = await privacyStats.fetchPrivacyStats()
+ XCTAssertEqual(stats, ["A": 1])
+ }
+
+ // MARK: - Helpers
+
+ func waitForStatsUpdateEvent(file: StaticString = #file, line: UInt = #line) async {
+ let expectation = self.expectation(description: "statsUpdate")
+ let cancellable = privacyStats.statsUpdatePublisher.sink { expectation.fulfill() }
+ await fulfillment(of: [expectation], timeout: 2)
+ cancellable.cancel()
+ }
+
+ /**
+ * Sets up an expectation with the fulfillment count specified by `count` parameter,
+ * then sets up Combine subscription, then calls the provided block and waits
+ * for time specified by `duration` before cancelling the subscription.
+ */
+ func waitForStatsUpdateEvents(for duration: TimeInterval, count: Int, _ block: () async -> Void) async throws {
+ let expectation = self.expectation(description: "statsUpdate")
+ expectation.expectedFulfillmentCount = count
+ let cancellable = privacyStats.statsUpdatePublisher.sink { expectation.fulfill() }
+
+ await block()
+
+ await fulfillment(of: [expectation], timeout: duration)
+ cancellable.cancel()
+ }
+}
diff --git a/Tests/PrivacyStatsTests/PrivacyStatsUtilsTests.swift b/Tests/PrivacyStatsTests/PrivacyStatsUtilsTests.swift
new file mode 100644
index 000000000..ec0e7e606
--- /dev/null
+++ b/Tests/PrivacyStatsTests/PrivacyStatsUtilsTests.swift
@@ -0,0 +1,360 @@
+//
+// PrivacyStatsUtilsTests.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 Persistence
+import XCTest
+@testable import PrivacyStats
+
+final class PrivacyStatsUtilsTests: XCTestCase {
+ var databaseProvider: TestPrivacyStatsDatabaseProvider!
+ var database: CoreDataDatabase!
+
+ override func setUp() async throws {
+ databaseProvider = TestPrivacyStatsDatabaseProvider(databaseName: type(of: self).description())
+ databaseProvider.initializeDatabase()
+ database = databaseProvider.database
+ }
+
+ override func tearDown() async throws {
+ databaseProvider.tearDownDatabase()
+ }
+
+ // MARK: - fetchOrInsertCurrentStats
+
+ func testWhenThereAreNoObjectsForCompaniesThenFetchOrInsertCurrentStatsInsertsNewObjects() {
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+ context.performAndWait {
+ let currentPackTimestamp = Date.currentPrivacyStatsPackTimestamp
+ let companyNames: Set = ["A", "B", "C", "D"]
+
+ var returnedEntities: [DailyBlockedTrackersEntity] = []
+ do {
+ returnedEntities = try PrivacyStatsUtils.fetchOrInsertCurrentStats(for: companyNames, in: context)
+ } catch {
+ XCTFail("Should not throw")
+ }
+
+ let insertedEntities = context.insertedObjects.compactMap { $0 as? DailyBlockedTrackersEntity }
+
+ XCTAssertEqual(returnedEntities.count, 4)
+ XCTAssertEqual(insertedEntities.count, 4)
+ XCTAssertEqual(Set(insertedEntities.map(\.companyName)), companyNames)
+ XCTAssertEqual(Set(insertedEntities.map(\.companyName)), Set(returnedEntities.map(\.companyName)))
+
+ // All inserted entries have the same timestamp
+ XCTAssertEqual(Set(insertedEntities.map(\.timestamp)), [currentPackTimestamp])
+
+ // All inserted entries have the count of 0
+ XCTAssertEqual(Set(insertedEntities.map(\.count)), [0])
+ }
+ }
+
+ func testWhenThereAreExistingObjectsForCompaniesThenFetchOrInsertCurrentStatsReturnsThem() throws {
+ let date = Date()
+
+ try databaseProvider.addObjects { context in
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 123, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "B", count: 4567, context: context)
+ ]
+ }
+
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+ context.performAndWait {
+ let companyNames: Set = ["A", "B", "C", "D"]
+
+ var returnedEntities: [DailyBlockedTrackersEntity] = []
+ do {
+ returnedEntities = try PrivacyStatsUtils.fetchOrInsertCurrentStats(for: companyNames, in: context)
+ } catch {
+ XCTFail("Should not throw")
+ }
+
+ let insertedEntities = context.insertedObjects.compactMap { $0 as? DailyBlockedTrackersEntity }
+
+ XCTAssertEqual(returnedEntities.count, 4)
+ XCTAssertEqual(insertedEntities.count, 2)
+ XCTAssertEqual(Set(returnedEntities.map(\.companyName)), companyNames)
+ XCTAssertEqual(Set(insertedEntities.map(\.companyName)), ["C", "D"])
+
+ do {
+ let companyA = try XCTUnwrap(returnedEntities.first { $0.companyName == "A" })
+ let companyB = try XCTUnwrap(returnedEntities.first { $0.companyName == "B" })
+
+ XCTAssertEqual(companyA.count, 123)
+ XCTAssertEqual(companyB.count, 4567)
+ } catch {
+ XCTFail("Should find companies A and B")
+ }
+ }
+ }
+
+ // MARK: - loadCurrentDayStats
+
+ func testWhenThereAreNoObjectsInDatabaseThenLoadCurrentDayStatsIsEmpty() throws {
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ let currentDayStats = try PrivacyStatsUtils.loadCurrentDayStats(in: context)
+ XCTAssertTrue(currentDayStats.isEmpty)
+ } catch {
+ XCTFail("Should not throw")
+ }
+ }
+ }
+
+ func testWhenThereAreObjectsInDatabaseForPreviousDaysThenLoadCurrentDayStatsIsEmpty() throws {
+ let date = Date()
+
+ try databaseProvider.addObjects { context in
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "A", count: 123, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(2), companyName: "B", count: 4567, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(5), companyName: "C", count: 890, context: context)
+ ]
+ }
+
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ let currentDayStats = try PrivacyStatsUtils.loadCurrentDayStats(in: context)
+ XCTAssertTrue(currentDayStats.isEmpty)
+ } catch {
+ XCTFail("Should not throw")
+ }
+ }
+ }
+
+ func testThatObjectsWithZeroCountAreNotReportedByLoadCurrentDayStats() throws {
+ let date = Date()
+
+ try databaseProvider.addObjects { context in
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 0, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "B", count: 0, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "C", count: 0, context: context)
+ ]
+ }
+
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ let currentDayStats = try PrivacyStatsUtils.loadCurrentDayStats(in: context)
+ XCTAssertTrue(currentDayStats.isEmpty)
+ } catch {
+ XCTFail("Should not throw")
+ }
+ }
+ }
+
+ func testThatObjectsWithNonZeroCountAreReportedByLoadCurrentDayStats() throws {
+ let date = Date()
+
+ try databaseProvider.addObjects { context in
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 150, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "B", count: 400, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "C", count: 84, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "D", count: 5, context: context)
+ ]
+ }
+
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ let currentDayStats = try PrivacyStatsUtils.loadCurrentDayStats(in: context)
+ XCTAssertEqual(currentDayStats, ["A": 150, "B": 400, "C": 84, "D": 5])
+ } catch {
+ XCTFail("Should not throw")
+ }
+ }
+ }
+
+ // MARK: - load7DayStats
+
+ func testWhenThereAreNoObjectsInDatabaseThenLoad7DayStatsIsEmpty() throws {
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ let stats = try PrivacyStatsUtils.load7DayStats(in: context)
+ XCTAssertTrue(stats.isEmpty)
+ } catch {
+ XCTFail("Should not throw")
+ }
+ }
+ }
+
+ func testWhenThereAreObjectsInDatabaseFrom7DaysAgoOrMoreThenLoad7DayStatsIsEmpty() throws {
+ let date = Date()
+
+ try databaseProvider.addObjects { context in
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(10), companyName: "A", count: 123, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(20), companyName: "B", count: 4567, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "C", count: 890, context: context)
+ ]
+ }
+
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ let stats = try PrivacyStatsUtils.load7DayStats(in: context)
+ XCTAssertTrue(stats.isEmpty)
+ } catch {
+ XCTFail("Should not throw")
+ }
+ }
+ }
+
+ func testThatObjectsWithZeroCountAreNotReportedByLoad7DayStats() throws {
+ let date = Date()
+
+ try databaseProvider.addObjects { context in
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 0, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(4), companyName: "B", count: 0, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "C", count: 0, context: context)
+ ]
+ }
+
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ let stats = try PrivacyStatsUtils.load7DayStats(in: context)
+ XCTAssertTrue(stats.isEmpty)
+ } catch {
+ XCTFail("Should not throw")
+ }
+ }
+ }
+
+ func testThatObjectsWithNonZeroCountAreReportedByLoad7DayStats() throws {
+ let date = Date()
+
+ try databaseProvider.addObjects { context in
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "A", count: 150, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(1), companyName: "B", count: 400, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(2), companyName: "C", count: 84, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "D", count: 5, context: context)
+ ]
+ }
+
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ let stats = try PrivacyStatsUtils.load7DayStats(in: context)
+ XCTAssertEqual(stats, ["A": 150, "B": 400, "C": 84, "D": 5])
+ } catch {
+ XCTFail("Should not throw")
+ }
+ }
+ }
+
+ // MARK: - deleteOutdatedPacks
+
+ func testWhenDeleteOutdatedPacksIsCalledThenObjectsFrom7DaysAgoOrMoreAreDeleted() throws {
+ let date = Date()
+
+ try databaseProvider.addObjects { context in
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "C", count: 1, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(4), companyName: "C", count: 2, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "C", count: 3, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(7), companyName: "C", count: 4, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(8), companyName: "C", count: 5, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(100), companyName: "C", count: 6, context: context)
+ ]
+ }
+
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ try PrivacyStatsUtils.deleteOutdatedPacks(in: context)
+
+ let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest())
+ XCTAssertEqual(Set(allObjects.map(\.count)), [1, 2, 3])
+ } catch {
+ XCTFail("Should not throw")
+ }
+ }
+ }
+
+ func testWhenObjectsFrom7DaysAgoOrMoreAreNotPresentThenDeleteOutdatedPacksHasNoEffect() throws {
+ let date = Date()
+
+ try databaseProvider.addObjects { context in
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "C", count: 1, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(4), companyName: "C", count: 2, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "C", count: 3, context: context)
+ ]
+ }
+
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ try PrivacyStatsUtils.deleteOutdatedPacks(in: context)
+
+ let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest())
+ XCTAssertEqual(allObjects.count, 3)
+ } catch {
+ XCTFail("Should not throw")
+ }
+ }
+ }
+
+ // MARK: - deleteAllStats
+
+ func testThatDeleteAllStatsRemovesAllDatabaseObjects() throws {
+ let date = Date()
+
+ try databaseProvider.addObjects { context in
+ return [
+ DailyBlockedTrackersEntity.make(timestamp: date, companyName: "C", count: 1, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(4), companyName: "C", count: 2, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(6), companyName: "C", count: 3, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(60), companyName: "C", count: 3, context: context),
+ DailyBlockedTrackersEntity.make(timestamp: date.daysAgo(600), companyName: "C", count: 3, context: context)
+ ]
+ }
+
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+
+ context.performAndWait {
+ do {
+ try PrivacyStatsUtils.deleteAllStats(in: context)
+
+ let allObjects = try context.fetch(DailyBlockedTrackersEntity.fetchRequest())
+ XCTAssertTrue(allObjects.isEmpty)
+ } catch {
+ XCTFail("Should not throw")
+ }
+ }
+ }
+}
diff --git a/Tests/PrivacyStatsTests/TestPrivacyStatsDatabaseProvider.swift b/Tests/PrivacyStatsTests/TestPrivacyStatsDatabaseProvider.swift
new file mode 100644
index 000000000..2cb210f0b
--- /dev/null
+++ b/Tests/PrivacyStatsTests/TestPrivacyStatsDatabaseProvider.swift
@@ -0,0 +1,65 @@
+//
+// TestPrivacyStatsDatabaseProvider.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 Persistence
+import XCTest
+@testable import PrivacyStats
+
+final class TestPrivacyStatsDatabaseProvider: PrivacyStatsDatabaseProviding {
+ let databaseName: String
+ var database: CoreDataDatabase!
+ var location: URL!
+
+ init(databaseName: String) {
+ self.databaseName = databaseName
+ }
+
+ init(databaseName: String, location: URL) {
+ self.databaseName = databaseName
+ self.location = location
+ }
+
+ @discardableResult
+ func initializeDatabase() -> CoreDataDatabase {
+ if location == nil {
+ location = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ }
+ let model = CoreDataDatabase.loadModel(from: PrivacyStats.bundle, named: "PrivacyStats")!
+ database = CoreDataDatabase(name: databaseName, containerLocation: location, model: model)
+ database.loadStore()
+ return database
+ }
+
+ func tearDownDatabase() {
+ try? database.tearDown(deleteStores: true)
+ database = nil
+ try? FileManager.default.removeItem(at: location)
+ }
+
+ func addObjects(_ objects: (NSManagedObjectContext) -> [DailyBlockedTrackersEntity], file: StaticString = #file, line: UInt = #line) throws {
+ let context = database.makeContext(concurrencyType: .privateQueueConcurrencyType)
+ context.performAndWait {
+ _ = objects(context)
+ do {
+ try context.save()
+ } catch {
+ XCTFail("save failed: \(error)", file: file, line: line)
+ }
+ }
+ }
+}
diff --git a/Tests/RemoteMessagingTests/Mappers/DefaultRemoteMessagingSurveyURLBuilderTests.swift b/Tests/RemoteMessagingTests/Mappers/DefaultRemoteMessagingSurveyURLBuilderTests.swift
new file mode 100644
index 000000000..c6a490eae
--- /dev/null
+++ b/Tests/RemoteMessagingTests/Mappers/DefaultRemoteMessagingSurveyURLBuilderTests.swift
@@ -0,0 +1,127 @@
+//
+// DefaultRemoteMessagingSurveyURLBuilderTests.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 XCTest
+import BrowserServicesKitTestsUtils
+import RemoteMessagingTestsUtils
+@testable import Subscription
+@testable import RemoteMessaging
+
+class DefaultRemoteMessagingSurveyURLBuilderTests: XCTestCase {
+
+ func testAddingATBParameter() {
+ let builder = buildRemoteMessagingSurveyURLBuilder(atb: "v456-7")
+ let baseURL = URL(string: "https://duckduckgo.com")!
+ let finalURL = builder.add(parameters: [.atb], to: baseURL)
+
+ XCTAssertEqual(finalURL.absoluteString, "https://duckduckgo.com?atb=v456-7")
+ }
+
+ func testAddingATBVariantParameter() {
+ let builder = buildRemoteMessagingSurveyURLBuilder(variant: "test-variant")
+ let baseURL = URL(string: "https://duckduckgo.com")!
+ let finalURL = builder.add(parameters: [.atbVariant], to: baseURL)
+
+ XCTAssertEqual(finalURL.absoluteString, "https://duckduckgo.com?var=test-variant")
+ }
+
+ func testAddingLocaleParameter() {
+ let builder = buildRemoteMessagingSurveyURLBuilder(locale: Locale(identifier: "en_NZ"))
+ let baseURL = URL(string: "https://duckduckgo.com")!
+ let finalURL = builder.add(parameters: [.locale], to: baseURL)
+
+ XCTAssertEqual(finalURL.absoluteString, "https://duckduckgo.com?locale=en-NZ")
+ }
+
+ func testAddingPrivacyProParameters() {
+ let builder = buildRemoteMessagingSurveyURLBuilder()
+ let baseURL = URL(string: "https://duckduckgo.com")!
+ let finalURL = builder.add(parameters: [.privacyProStatus, .privacyProPlatform, .privacyProPlatform], to: baseURL)
+
+ XCTAssertEqual(finalURL.absoluteString, "https://duckduckgo.com?ppro_status=auto_renewable&ppro_platform=apple&ppro_platform=apple")
+ }
+
+ func testAddingVPNUsageParameters() {
+ let builder = buildRemoteMessagingSurveyURLBuilder(vpnDaysSinceActivation: 10, vpnDaysSinceLastActive: 5)
+ let baseURL = URL(string: "https://duckduckgo.com")!
+ let finalURL = builder.add(parameters: [.vpnFirstUsed, .vpnLastUsed], to: baseURL)
+
+ XCTAssertEqual(finalURL.absoluteString, "https://duckduckgo.com?vpn_first_used=10&vpn_last_used=5")
+ }
+
+ func testAddingParametersToURLThatAlreadyHasThem() {
+ let builder = buildRemoteMessagingSurveyURLBuilder(vpnDaysSinceActivation: 10, vpnDaysSinceLastActive: 5)
+ let baseURL = URL(string: "https://duckduckgo.com?param=test")!
+ let finalURL = builder.add(parameters: [.vpnFirstUsed, .vpnLastUsed], to: baseURL)
+
+ XCTAssertEqual(finalURL.absoluteString, "https://duckduckgo.com?param=test&vpn_first_used=10&vpn_last_used=5")
+ }
+
+ private func buildRemoteMessagingSurveyURLBuilder(
+ atb: String = "v123-4",
+ variant: String = "var",
+ vpnDaysSinceActivation: Int = 2,
+ vpnDaysSinceLastActive: Int = 1,
+ locale: Locale = Locale(identifier: "en_US")
+ ) -> DefaultRemoteMessagingSurveyURLBuilder {
+
+ let mockStatisticsStore = MockStatisticsStore()
+ mockStatisticsStore.atb = atb
+ mockStatisticsStore.variant = variant
+
+ let vpnActivationDateStore = MockVPNActivationDateStore(
+ daysSinceActivation: vpnDaysSinceActivation,
+ daysSinceLastActive: vpnDaysSinceLastActive
+ )
+
+ let subscription = DDGSubscription(productId: "product-id",
+ name: "product-name",
+ billingPeriod: .monthly,
+ startedAt: Date(timeIntervalSince1970: 1000),
+ expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
+ platform: .apple,
+ status: .autoRenewable)
+
+ return DefaultRemoteMessagingSurveyURLBuilder(
+ statisticsStore: mockStatisticsStore,
+ vpnActivationDateStore: vpnActivationDateStore,
+ subscription: subscription,
+ localeIdentifier: locale.identifier)
+ }
+
+}
+
+private class MockVPNActivationDateStore: VPNActivationDateProviding {
+
+ var _daysSinceActivation: Int
+ var _daysSinceLastActive: Int
+
+ init(daysSinceActivation: Int, daysSinceLastActive: Int) {
+ self._daysSinceActivation = daysSinceActivation
+ self._daysSinceLastActive = daysSinceLastActive
+ }
+
+ func daysSinceActivation() -> Int? {
+ return _daysSinceActivation
+ }
+
+ func daysSinceLastActive() -> Int? {
+ return _daysSinceLastActive
+ }
+
+}