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 + } + +}