From a2db6aea9a89cfbad39ec108196b8b59fd8ddb7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81yp?= Date: Thu, 29 Feb 2024 20:28:18 +0100 Subject: [PATCH 1/4] Add simple user behavior monitoring to better address potential breakages --- Core/PixelEvent.swift | 17 ++ Core/UserDefaultsPropertyWrapper.swift | 4 + DuckDuckGo.xcodeproj/project.pbxproj | 28 +++ DuckDuckGo/AppDelegate.swift | 2 + DuckDuckGo/AppDependencyProvider.swift | 7 + DuckDuckGo/MainViewController.swift | 3 +- .../PrivacyDashboardViewController.swift | 1 + DuckDuckGo/TabViewController.swift | 3 + ...bViewControllerBrowsingMenuExtension.swift | 3 + DuckDuckGo/UserBehaviorEvent.swift | 32 ++++ DuckDuckGo/UserBehaviorMonitor.swift | 89 +++++++++ .../UserBehaviorMonitorTests.swift | 173 ++++++++++++++++++ 12 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 DuckDuckGo/UserBehaviorEvent.swift create mode 100644 DuckDuckGo/UserBehaviorMonitor.swift create mode 100644 DuckDuckGoTests/UserBehaviorMonitorTests.swift diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index b1766ba89c..291db3d335 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -526,6 +526,14 @@ extension Pixel { case compilationFailed case appRatingPromptFetchError + + case userBehaviorReloadTwice + case userBehaviorReloadAndRestart + case userBehaviorReloadAndFireButton + case userBehaviorReloadAndOpenSettings + case userBehaviorReloadAndTogglePrivacyControls + case userBehaviorFireButtonAndRestart + case userBehaviorFireButtonAndTogglePrivacyControls } } @@ -1024,6 +1032,15 @@ extension Pixel.Event { case .debugReturnUserUpdateATB: return "m_debug_return_user_update_atb" case .appRatingPromptFetchError: return "m_d_app_rating_prompt_fetch_error" + + // MARK: - User behavior + case .userBehaviorReloadTwice: return "m_reload-twice" + case .userBehaviorReloadAndRestart: return "m_reload-and-restart" + case .userBehaviorReloadAndFireButton: return "m_reload-and-fire-button" + case .userBehaviorReloadAndOpenSettings: return "m_reload-and-open-settings" + case .userBehaviorReloadAndTogglePrivacyControls: return "m_reload-and-toggle-privacy-controls" + case .userBehaviorFireButtonAndRestart: return "m_fire-button-and-restart" + case .userBehaviorFireButtonAndTogglePrivacyControls: return "m_fire-button-and-toggle-privacy-controls" } } diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 7d4a96f316..9ab2131af7 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -123,6 +123,10 @@ public struct UserDefaultsWrapper { case privacyConfigCustomURL = "com.duckduckgo.ios.privacyConfigCustomURL" case subscriptionIsActive = "com.duckduckgo.ios.subscruption.isActive" + + case didRefreshTimestamp = "com.duckduckgo.ios.userBehavior.didRefreshTimestamp" + case didBurnTimestamp = "com.duckduckgo.ios.userBehavior.didBurnTimestamp" + } private let key: Key diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 8f41b35304..a733aff363 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -749,6 +749,9 @@ CB2A7EEF283D185100885F67 /* RulesCompilationMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2A7EEE283D185100885F67 /* RulesCompilationMonitor.swift */; }; CB2A7EF128410DF700885F67 /* PixelEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2A7EF028410DF700885F67 /* PixelEvent.swift */; }; CB2A7EF4285383B300885F67 /* AppLastCompiledRulesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2A7EF3285383B300885F67 /* AppLastCompiledRulesStore.swift */; }; + CB48D3322B90CE9F00631D8B /* UserBehaviorEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB48D3302B90CE9F00631D8B /* UserBehaviorEvent.swift */; }; + CB48D3332B90CE9F00631D8B /* UserBehaviorMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB48D3312B90CE9F00631D8B /* UserBehaviorMonitor.swift */; }; + CB48D3372B90DF2000631D8B /* UserBehaviorMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB48D3352B90CECD00631D8B /* UserBehaviorMonitorTests.swift */; }; CB5516D0286500290079B175 /* TrackerRadarIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85519124247468580010FDD0 /* TrackerRadarIntegrationTests.swift */; }; CB5516D1286500290079B175 /* ContentBlockingRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CA904C24FD2DB000D41DDF /* ContentBlockingRulesTests.swift */; }; CB5516D2286500290079B175 /* AtbServerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F21DBD21121147002631A6 /* AtbServerTests.swift */; }; @@ -2393,6 +2396,9 @@ CB2A7EF3285383B300885F67 /* AppLastCompiledRulesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLastCompiledRulesStore.swift; sourceTree = ""; }; CB2C47822AF6D55800AEDCD9 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; CB4448752AF6D51D001F93F7 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/InfoPlist.strings; sourceTree = ""; }; + CB48D3302B90CE9F00631D8B /* UserBehaviorEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserBehaviorEvent.swift; sourceTree = ""; }; + CB48D3312B90CE9F00631D8B /* UserBehaviorMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserBehaviorMonitor.swift; sourceTree = ""; }; + CB48D3352B90CECD00631D8B /* UserBehaviorMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserBehaviorMonitorTests.swift; sourceTree = ""; }; CB5038622AF6D563007FD69F /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; CB6ABD002AF6D52B004A8224 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/InfoPlist.strings; sourceTree = ""; }; CB6CE65B2AF6D4EE00119848 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -3721,6 +3727,7 @@ 84E341941E2F7EFB00BDBA6F /* DuckDuckGo */ = { isa = PBXGroup; children = ( + CB48D32F2B90CE8500631D8B /* UserBehaviorMonitor */, EE3B98EA2A9634CC002F63A0 /* DuckDuckGoAlpha.entitlements */, CB258D1129A4F1BB00DEBA24 /* Configuration */, 1E908BED29827C480008C8F3 /* Autoconsent */, @@ -4486,6 +4493,23 @@ path = Configuration; sourceTree = ""; }; + CB48D32F2B90CE8500631D8B /* UserBehaviorMonitor */ = { + isa = PBXGroup; + children = ( + CB48D3302B90CE9F00631D8B /* UserBehaviorEvent.swift */, + CB48D3312B90CE9F00631D8B /* UserBehaviorMonitor.swift */, + ); + name = UserBehaviorMonitor; + sourceTree = ""; + }; + CB48D3342B90CEBD00631D8B /* UserBehaviorMonitor */ = { + isa = PBXGroup; + children = ( + CB48D3352B90CECD00631D8B /* UserBehaviorMonitorTests.swift */, + ); + name = UserBehaviorMonitor; + sourceTree = ""; + }; CBAA195627BFDD9800A4BD49 /* SmarterEncryption */ = { isa = PBXGroup; children = ( @@ -4912,6 +4936,7 @@ F12D98401F266B30003C2EE3 /* DuckDuckGo */ = { isa = PBXGroup; children = ( + CB48D3342B90CEBD00631D8B /* UserBehaviorMonitor */, F17669A21E411D63003D3222 /* Application */, 026F08B629B7DC130079B9DF /* AppTrackingProtection */, 981FED7222045FFA008488D7 /* AutoClear */, @@ -6594,6 +6619,7 @@ F4147354283BF834004AA7A5 /* AutofillContentScopeFeatureToggles.swift in Sources */, 986DA94A24884B18004A7E39 /* WebViewTransition.swift in Sources */, 31B524572715BB23002225AB /* WebJSAlert.swift in Sources */, + CB48D3322B90CE9F00631D8B /* UserBehaviorEvent.swift in Sources */, 8536A1FD2ACF114B003AC5BA /* Theme+DesignSystem.swift in Sources */, F114C55B1E66EB020018F95F /* NibLoading.swift in Sources */, D6BFCB612B7525160051FF81 /* SubscriptionPIRViewModel.swift in Sources */, @@ -6775,6 +6801,7 @@ 9821234E2B6D0A6300F08C57 /* UserAuthenticator.swift in Sources */, 310C4B47281B60E300BA79A9 /* AutofillLoginDetailsViewModel.swift in Sources */, 85EE7F572246685B000FE757 /* WebContainerViewController.swift in Sources */, + CB48D3332B90CE9F00631D8B /* UserBehaviorMonitor.swift in Sources */, 1EC458462948932500CB2B13 /* UIHostingControllerExtension.swift in Sources */, 1E4DCF4E27B6A69600961E25 /* DownloadsListHostingController.swift in Sources */, 850F93DB2B594AB800823EEA /* ZippedPassKitPreviewHelper.swift in Sources */, @@ -6988,6 +7015,7 @@ B6AD9E3A28D456820019CDE9 /* PrivacyConfigurationManagerMock.swift in Sources */, F189AED71F18F6DE001EBAE1 /* TabTests.swift in Sources */, F13B4BFB1F18E3D900814661 /* TabsModelPersistenceExtensionTests.swift in Sources */, + CB48D3372B90DF2000631D8B /* UserBehaviorMonitorTests.swift in Sources */, 8528AE7E212EF5FF00D0BD74 /* AppRatingPromptTests.swift in Sources */, 981FED692201FE69008488D7 /* AutoClearSettingsScreenTests.swift in Sources */, 4BC21A2F27238B7500229F0E /* RunLoopExtensionTests.swift in Sources */, diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 01bcadace1..82d6381202 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -329,6 +329,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { clearDebugWaitlistState() + AppDependencyProvider.shared.userBehaviorMonitor.handleAction(.reopenApp) + return true } diff --git a/DuckDuckGo/AppDependencyProvider.swift b/DuckDuckGo/AppDependencyProvider.swift index b23caebde0..55ae87a87b 100644 --- a/DuckDuckGo/AppDependencyProvider.swift +++ b/DuckDuckGo/AppDependencyProvider.swift @@ -24,6 +24,7 @@ import DDGSync import Bookmarks protocol DependencyProvider { + var appSettings: AppSettings { get } var variantManager: VariantManager { get } var internalUserDecider: InternalUserDecider { get } @@ -36,11 +37,14 @@ protocol DependencyProvider { var autofillLoginSession: AutofillLoginSession { get } var autofillNeverPromptWebsitesManager: AutofillNeverPromptWebsitesManager { get } var configurationManager: ConfigurationManager { get } + var userBehaviorMonitor: UserBehaviorMonitor { get } + } /// Provides dependencies for objects that are not directly instantiated /// through `init` call (e.g. ViewControllers created from Storyboards). class AppDependencyProvider: DependencyProvider { + static var shared: DependencyProvider = AppDependencyProvider() let appSettings: AppSettings = AppUserDefaults() @@ -60,4 +64,7 @@ class AppDependencyProvider: DependencyProvider { lazy var autofillNeverPromptWebsitesManager = AutofillNeverPromptWebsitesManager() let configurationManager = ConfigurationManager() + + let userBehaviorMonitor = UserBehaviorMonitor() + } diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 0860b07266..c2f67cd89d 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -2225,7 +2225,8 @@ extension MainViewController: AutoClearWorker { func forgetAllWithAnimation(transitionCompletion: (() -> Void)? = nil, showNextDaxDialog: Bool = false) { let spid = Instruments.shared.startTimedEvent(.clearingData) Pixel.fire(pixel: .forgetAllExecuted) - + AppDependencyProvider.shared.userBehaviorMonitor.handleAction(.burn) + tabManager.prepareAllTabsExceptCurrentForDataClearing() fireButtonAnimator.animate { diff --git a/DuckDuckGo/PrivacyDashboard/PrivacyDashboardViewController.swift b/DuckDuckGo/PrivacyDashboard/PrivacyDashboardViewController.swift index 68521a29dd..98283d584a 100644 --- a/DuckDuckGo/PrivacyDashboard/PrivacyDashboardViewController.swift +++ b/DuckDuckGo/PrivacyDashboard/PrivacyDashboardViewController.swift @@ -113,6 +113,7 @@ class PrivacyDashboardViewController: UIViewController { } contentBlockingManager.scheduleCompilation() + AppDependencyProvider.shared.userBehaviorMonitor.handleAction(.toggleProtections) } private func privacyDashboardCloseHandler() { diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index b0c7df98ea..da4fbb14f2 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -421,6 +421,7 @@ class TabViewController: UIViewController { guard let self else { return } self.reload() Pixel.fire(pixel: .pullToRefresh) + AppDependencyProvider.shared.userBehaviorMonitor.handleAction(.refresh) }, for: .valueChanged) webView.scrollView.refreshControl?.backgroundColor = .systemBackground @@ -2090,6 +2091,8 @@ extension TabViewController: UIGestureRecognizerDelegate { } else { reload() } + + AppDependencyProvider.shared.userBehaviorMonitor.handleAction(.refresh) } } diff --git a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift index b946236540..2d4f1397c3 100644 --- a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift @@ -403,6 +403,7 @@ extension TabViewController { private func onBrowsingSettingsAction() { Pixel.fire(pixel: .browsingMenuSettings) + AppDependencyProvider.shared.userBehaviorMonitor.handleAction(.openSettings) delegate?.tabDidRequestSettings(tab: self) } @@ -441,5 +442,7 @@ extension TabViewController { onAction: { [weak self] in self?.togglePrivacyProtection(domain: domain) }) + AppDependencyProvider.shared.userBehaviorMonitor.handleAction(.toggleProtections) } + } diff --git a/DuckDuckGo/UserBehaviorEvent.swift b/DuckDuckGo/UserBehaviorEvent.swift new file mode 100644 index 0000000000..1de6af8a92 --- /dev/null +++ b/DuckDuckGo/UserBehaviorEvent.swift @@ -0,0 +1,32 @@ +// +// UserBehaviorEvent.swift +// DuckDuckGo +// +// 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 + +public enum UserBehaviorEvent { + + case reloadTwice + case reloadAndRestart + case reloadAndFireButton + case reloadAndOpenSettings + case reloadAndTogglePrivacyControls + case fireButtonAndRestart + case fireButtonAndTogglePrivacyControls + +} diff --git a/DuckDuckGo/UserBehaviorMonitor.swift b/DuckDuckGo/UserBehaviorMonitor.swift new file mode 100644 index 0000000000..5fcd10af11 --- /dev/null +++ b/DuckDuckGo/UserBehaviorMonitor.swift @@ -0,0 +1,89 @@ +// +// UserBehaviorMonitor.swift +// DuckDuckGo +// +// 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 Common +import Core + +final class UserBehaviorMonitor { + + enum Action: Equatable { + + case refresh + case burn + case reopenApp + case openSettings + case toggleProtections + + } + + private let eventMapping: EventMapping + init(eventMapping: EventMapping = AppUserBehaviorMonitor.eventMapping) { + self.eventMapping = eventMapping + } + + @UserDefaultsWrapper(key: .didRefreshTimestamp, defaultValue: .distantPast) + private var didRefreshTimestamp: Date? + @UserDefaultsWrapper(key: .didBurnTimestamp, defaultValue: .distantPast) + private var didBurnTimestamp: Date? + + func handleAction(_ action: Action, date: Date = Date()) { + switch action { + case .refresh: + fireEventIfActionOccurredRecently(since: didRefreshTimestamp, eventToFire: .reloadTwice, within: 10.0) + didRefreshTimestamp = date + case .burn: + fireEventIfActionOccurredRecently(since: didRefreshTimestamp, eventToFire: .reloadAndFireButton) + didBurnTimestamp = date + case .reopenApp: + fireEventIfActionOccurredRecently(since: didRefreshTimestamp, eventToFire: .reloadAndRestart) + fireEventIfActionOccurredRecently(since: didBurnTimestamp, eventToFire: .fireButtonAndRestart) + case .openSettings: + fireEventIfActionOccurredRecently(since: didRefreshTimestamp, eventToFire: .reloadAndOpenSettings) + case .toggleProtections: + fireEventIfActionOccurredRecently(since: didRefreshTimestamp, eventToFire: .reloadAndTogglePrivacyControls) + fireEventIfActionOccurredRecently(since: didBurnTimestamp, eventToFire: .fireButtonAndTogglePrivacyControls) + } + + func fireEventIfActionOccurredRecently(since timestamp: Date?, eventToFire: UserBehaviorEvent, within interval: Double = 30.0) { + if let timestamp = timestamp, date.timeIntervalSince(timestamp) < interval { + eventMapping.fire(eventToFire) + } + } + } + +} + +final class AppUserBehaviorMonitor { + + static let eventMapping = EventMapping { event, error, parameters, onComplete in + let domainEvent: Pixel.Event + switch event { + case .reloadTwice: domainEvent = .userBehaviorReloadTwice + case .reloadAndRestart: domainEvent = .userBehaviorReloadAndRestart + case .reloadAndFireButton: domainEvent = .userBehaviorReloadAndFireButton + case .reloadAndOpenSettings: domainEvent = .userBehaviorReloadAndOpenSettings + case .reloadAndTogglePrivacyControls: domainEvent = .userBehaviorReloadAndTogglePrivacyControls + case .fireButtonAndRestart: domainEvent = .userBehaviorFireButtonAndRestart + case .fireButtonAndTogglePrivacyControls: domainEvent = .userBehaviorFireButtonAndTogglePrivacyControls + } + Pixel.fire(pixel: domainEvent, onComplete: onComplete) + } + +} diff --git a/DuckDuckGoTests/UserBehaviorMonitorTests.swift b/DuckDuckGoTests/UserBehaviorMonitorTests.swift new file mode 100644 index 0000000000..3e8b07396e --- /dev/null +++ b/DuckDuckGoTests/UserBehaviorMonitorTests.swift @@ -0,0 +1,173 @@ +// +// UserBehaviorMonitorTests.swift +// DuckDuckGo +// +// 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 Common +@testable import DuckDuckGo + +final class MockUserBehaviorEventsMapping: EventMapping { + static var events: [UserBehaviorEvent] = [] + + init() { + super.init { event, _, _, _ in + Self.events.append(event) + } + } + + override init(mapping: @escaping EventMapping.Mapping) { + fatalError("Use init()") + } +} + +final class UserBehaviorMonitorTests: XCTestCase { + + var eventMapping: MockUserBehaviorEventsMapping! + var monitor: UserBehaviorMonitor! + + override func setUp() { + super.setUp() + eventMapping = MockUserBehaviorEventsMapping() + MockUserBehaviorEventsMapping.events.removeAll() + monitor = UserBehaviorMonitor(eventMapping: eventMapping) + } + + private var events: [UserBehaviorEvent] { MockUserBehaviorEventsMapping.events } + + // - MARK: Behavior testing + // Expecting events + + func testWhenUserRefreshesTwiceItSendsReloadTwiceEvent() { + monitor.handleAction(.refresh) + monitor.handleAction(.refresh) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first!, .reloadTwice) + } + + func testWhenUserRefreshesAndThenReopensAppItSendsReloadAndRestartEvent() { + monitor.handleAction(.refresh) + monitor.handleAction(.reopenApp) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first!, .reloadAndRestart) + } + + func testWhenUserRefreshesAndThenUsesFireButtonItSendsReloadAndFireButtonEvent() { + monitor.handleAction(.refresh) + monitor.handleAction(.burn) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first!, .reloadAndFireButton) + } + + func testWhenUserRefreshesAndThenOpensSettingsItSendsReloadAndOpenSettingsEvent() { + monitor.handleAction(.refresh) + monitor.handleAction(.openSettings) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first!, .reloadAndOpenSettings) + } + + func testWhenUserRefreshesAndThenTogglesProtectionsItSendsReloadAndTogglePrivacyControlsEvent() { + monitor.handleAction(.refresh) + monitor.handleAction(.toggleProtections) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first!, .reloadAndTogglePrivacyControls) + } + + func testWhenUserUsesFireButtonAndThenReopensAppItSendsFireButtonAndRestartEvent() { + monitor.handleAction(.burn) + monitor.handleAction(.reopenApp) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first!, .fireButtonAndRestart) + } + + func testWhenUserUsesFireButtonAndThenTogglesProtectionsItSendsFireButtonAndTogglePrivacyControlsEvent() { + monitor.handleAction(.burn) + monitor.handleAction(.toggleProtections) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first!, .fireButtonAndTogglePrivacyControls) + } + + func testWhenUserUsesFireButtonThenOpensSettingsThenReopensAppItSendsFireButtonAndRestartEvent() { + monitor.handleAction(.burn) + monitor.handleAction(.openSettings) + monitor.handleAction(.reopenApp) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first!, .fireButtonAndRestart) + } + + func testWhenUserUsesFireButtonThenRefreshesThenReopensAppItSendsTwoEvents() { + monitor.handleAction(.burn) + monitor.handleAction(.refresh) + monitor.handleAction(.reopenApp) + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0], .reloadAndRestart) + XCTAssertEqual(events[1], .fireButtonAndRestart) + } + + func testWhenUserRefreshesThenReopensAppThenUsesFireButtonThenItSendsThreeEvents() { + monitor.handleAction(.refresh) + monitor.handleAction(.burn) + monitor.handleAction(.reopenApp) + XCTAssertEqual(events.count, 3) + XCTAssertEqual(events[0], .reloadAndFireButton) + XCTAssertEqual(events[1], .reloadAndRestart) + XCTAssertEqual(events[2], .fireButtonAndRestart) + } + + // Not expecting any events + + func testWhenUserUsesFireButtonAndThenRefreshesItShouldNotSendAnyEvent() { + monitor.handleAction(.burn) + monitor.handleAction(.refresh) + XCTAssertTrue(events.isEmpty) + } + + // Timing + + func testFireReloadTwiceEventOnlyIfItHappenedWithin10seconds() { + let date = Date() + monitor.handleAction(.refresh, date: date) + monitor.handleAction(.refresh, date: date + 10) // 10 seconds after the first event + XCTAssertTrue(events.isEmpty) + monitor.handleAction(.refresh, date: date + 15) // 5 seconds after the second event + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first!, .reloadTwice) + } + + func testFireReloadAndRestartEventOnlyIfItHappenedWithin30seconds() { + let date = Date() + monitor.handleAction(.refresh, date: date) + monitor.handleAction(.reopenApp, date: date + 30) // 30 seconds after the first event + XCTAssertTrue(events.isEmpty) + monitor.handleAction(.refresh, date: date + 30) + monitor.handleAction(.reopenApp, date: date + 50) // 20 seconds after the second event + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first!, .reloadAndRestart) + } + + func testFireButtonAndRestartEventOnlyIfItHappenedWithin30seconds() { + let date = Date() + monitor.handleAction(.burn, date: date) + monitor.handleAction(.reopenApp, date: date + 30) // 30 seconds after the first event + XCTAssertTrue(events.isEmpty) + monitor.handleAction(.burn, date: date + 30) + monitor.handleAction(.reopenApp, date: date + 50) // 20 seconds after the second event + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first!, .fireButtonAndRestart) + } + +} From 8ed2af987d06f8ee510972b14e518c899e0977b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81yp?= Date: Thu, 29 Feb 2024 20:39:40 +0100 Subject: [PATCH 2/4] Fix swiftlint issues --- DuckDuckGo/UserBehaviorMonitor.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/UserBehaviorMonitor.swift b/DuckDuckGo/UserBehaviorMonitor.swift index 5fcd10af11..de76c07152 100644 --- a/DuckDuckGo/UserBehaviorMonitor.swift +++ b/DuckDuckGo/UserBehaviorMonitor.swift @@ -72,7 +72,7 @@ final class UserBehaviorMonitor { final class AppUserBehaviorMonitor { - static let eventMapping = EventMapping { event, error, parameters, onComplete in + static let eventMapping = EventMapping { event, _, _, _ in let domainEvent: Pixel.Event switch event { case .reloadTwice: domainEvent = .userBehaviorReloadTwice @@ -83,7 +83,7 @@ final class AppUserBehaviorMonitor { case .fireButtonAndRestart: domainEvent = .userBehaviorFireButtonAndRestart case .fireButtonAndTogglePrivacyControls: domainEvent = .userBehaviorFireButtonAndTogglePrivacyControls } - Pixel.fire(pixel: domainEvent, onComplete: onComplete) + Pixel.fire(pixel: domainEvent) } } From d7441a774f4e0256299b6c01a86828059e2c8250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81yp?= Date: Thu, 29 Feb 2024 20:42:34 +0100 Subject: [PATCH 3/4] Fix tests --- DuckDuckGoTests/MockDependencyProvider.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DuckDuckGoTests/MockDependencyProvider.swift b/DuckDuckGoTests/MockDependencyProvider.swift index 7676843d3c..0b53f456a4 100644 --- a/DuckDuckGoTests/MockDependencyProvider.swift +++ b/DuckDuckGoTests/MockDependencyProvider.swift @@ -36,6 +36,7 @@ class MockDependencyProvider: DependencyProvider { var autofillLoginSession: AutofillLoginSession var autofillNeverPromptWebsitesManager: AutofillNeverPromptWebsitesManager var configurationManager: ConfigurationManager + var userBehaviorMonitor: UserBehaviorMonitor init() { let defaultProvider = AppDependencyProvider() @@ -51,5 +52,6 @@ class MockDependencyProvider: DependencyProvider { autofillLoginSession = defaultProvider.autofillLoginSession autofillNeverPromptWebsitesManager = defaultProvider.autofillNeverPromptWebsitesManager configurationManager = defaultProvider.configurationManager + userBehaviorMonitor = defaultProvider.userBehaviorMonitor } } From 456522cff4da4a86080d408c526fab2a9605ddf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81yp?= Date: Thu, 29 Feb 2024 22:32:39 +0100 Subject: [PATCH 4/4] Fix tests --- DuckDuckGo/UserBehaviorMonitor.swift | 36 ++++++++++++++++--- DuckDuckGo/en.lproj/Localizable.strings | 2 +- .../UserBehaviorMonitorTests.swift | 24 ++++++++----- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/DuckDuckGo/UserBehaviorMonitor.swift b/DuckDuckGo/UserBehaviorMonitor.swift index de76c07152..076c665c3c 100644 --- a/DuckDuckGo/UserBehaviorMonitor.swift +++ b/DuckDuckGo/UserBehaviorMonitor.swift @@ -21,6 +21,23 @@ import Foundation import Common import Core +protocol UserBehaviorStoring { + + var didRefreshTimestamp: Date? { get set } + var didBurnTimestamp: Date? { get set } + +} + +final class UserBehaviorStore: UserBehaviorStoring { + + @UserDefaultsWrapper(key: .didRefreshTimestamp, defaultValue: .distantPast) + var didRefreshTimestamp: Date? + + @UserDefaultsWrapper(key: .didBurnTimestamp, defaultValue: .distantPast) + var didBurnTimestamp: Date? + +} + final class UserBehaviorMonitor { enum Action: Equatable { @@ -34,14 +51,23 @@ final class UserBehaviorMonitor { } private let eventMapping: EventMapping - init(eventMapping: EventMapping = AppUserBehaviorMonitor.eventMapping) { + private var store: UserBehaviorStoring + + init(eventMapping: EventMapping = AppUserBehaviorMonitor.eventMapping, + store: UserBehaviorStoring = UserBehaviorStore()) { self.eventMapping = eventMapping + self.store = store } - @UserDefaultsWrapper(key: .didRefreshTimestamp, defaultValue: .distantPast) - private var didRefreshTimestamp: Date? - @UserDefaultsWrapper(key: .didBurnTimestamp, defaultValue: .distantPast) - private var didBurnTimestamp: Date? + var didRefreshTimestamp: Date? { + get { store.didRefreshTimestamp } + set { store.didRefreshTimestamp = newValue } + } + + var didBurnTimestamp: Date? { + get { store.didBurnTimestamp } + set { store.didBurnTimestamp = newValue } + } func handleAction(_ action: Action, date: Date = Date()) { switch action { diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 2445871097..6217982ed6 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -2005,7 +2005,7 @@ But if you *do* want a peek under the hood, you can find more information about "subscription.manage.devices" = "Manage Devices"; /* Description for Email Management options */ -"subscription.manage.email.description" = "You can use this email to activate your subscription from browser settings in the DuckDuckGo app on your other devices."; +"subscription.manage.email.description" = "You can use this email to activate your subscription on your other devices."; /* Manage Plan header */ "subscription.manage.plan" = "Manage Plan"; diff --git a/DuckDuckGoTests/UserBehaviorMonitorTests.swift b/DuckDuckGoTests/UserBehaviorMonitorTests.swift index 3e8b07396e..d95cf04c17 100644 --- a/DuckDuckGoTests/UserBehaviorMonitorTests.swift +++ b/DuckDuckGoTests/UserBehaviorMonitorTests.swift @@ -22,11 +22,10 @@ import Common @testable import DuckDuckGo final class MockUserBehaviorEventsMapping: EventMapping { - static var events: [UserBehaviorEvent] = [] - init() { + init(captureEvent: @escaping (UserBehaviorEvent) -> Void) { super.init { event, _, _, _ in - Self.events.append(event) + captureEvent(event) } } @@ -35,20 +34,29 @@ final class MockUserBehaviorEventsMapping: EventMapping { } } +final class MockUserBehaviorStore: UserBehaviorStoring { + + var didRefreshTimestamp: Date? + var didBurnTimestamp: Date? + +} + final class UserBehaviorMonitorTests: XCTestCase { var eventMapping: MockUserBehaviorEventsMapping! var monitor: UserBehaviorMonitor! + var events: [UserBehaviorEvent] = [] override func setUp() { super.setUp() - eventMapping = MockUserBehaviorEventsMapping() - MockUserBehaviorEventsMapping.events.removeAll() - monitor = UserBehaviorMonitor(eventMapping: eventMapping) + events.removeAll() + eventMapping = MockUserBehaviorEventsMapping(captureEvent: { event in + self.events.append(event) + }) + monitor = UserBehaviorMonitor(eventMapping: eventMapping, + store: MockUserBehaviorStore()) } - private var events: [UserBehaviorEvent] { MockUserBehaviorEventsMapping.events } - // - MARK: Behavior testing // Expecting events