diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index b5b6b5384c..e5cb5bcdf9 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -298,6 +298,8 @@ extension Pixel { case autofillLoginsReportConfirmationPromptConfirmed case autofillLoginsReportConfirmationPromptDismissed + case autofillManagementScreenVisitSurveyAvailable + case getDesktopCopy case getDesktopShare @@ -1105,9 +1107,11 @@ extension Pixel.Event { case .autofillLoginsReportFailure: return "autofill_logins_report_failure" case .autofillLoginsReportAvailable: return "autofill_logins_report_available" - case .autofillLoginsReportConfirmationPromptDisplayed: return "autofill_logins_report_confirmation_prompt_displayed" - case .autofillLoginsReportConfirmationPromptConfirmed: return "autofill_logins_report_confirmation_prompt_confirmed" - case .autofillLoginsReportConfirmationPromptDismissed: return "autofill_logins_report_confirmation_prompt_dismissed" + case .autofillLoginsReportConfirmationPromptDisplayed: return "autofill_logins_report_confirmation_displayed" + case .autofillLoginsReportConfirmationPromptConfirmed: return "autofill_logins_report_confirmation_confirmed" + case .autofillLoginsReportConfirmationPromptDismissed: return "autofill_logins_report_confirmation_dismissed" + + case .autofillManagementScreenVisitSurveyAvailable: return "m_autofill_management_screen_visit_survey_available" case .getDesktopCopy: return "m_get_desktop_copy" case .getDesktopShare: return "m_get_desktop_share" diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 1c1b5216a7..4f221722cc 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -92,6 +92,7 @@ public struct UserDefaultsWrapper { case autofillSearchDauDate = "com.duckduckgo.app.autofill.SearchDauDate" case autofillFillDate = "com.duckduckgo.app.autofill.FillDate" case autofillOnboardedUser = "com.duckduckgo.app.autofill.OnboardedUser" + case autofillSurveysCompleted = "com.duckduckgo.app.autofill.SurveysCompleted" case syncPromoBookmarksDismissed = "com.duckduckgo.app.sync.PromoBookmarksDismissed" case syncPromoPasswordsDismissed = "com.duckduckgo.app.sync.PromoPasswordsDismissed" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 0c0703e228..9a57964978 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -850,6 +850,11 @@ C185ED672BD43DA100BAE9DC /* ImportPasswordsStatusHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C185ED632BD438AF00BAE9DC /* ImportPasswordsStatusHandlerTests.swift */; }; C18ED43A2AB6F77600BF3805 /* AutofillSettingsEnableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18ED4392AB6F77600BF3805 /* AutofillSettingsEnableFooterView.swift */; }; C18ED43C2AB8364400BF3805 /* FileTextPreviewDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18ED43B2AB8364400BF3805 /* FileTextPreviewDebugViewController.swift */; }; + C1935A0E2C88D11D001AD72D /* AutofillSurveyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1935A0D2C88D11D001AD72D /* AutofillSurveyView.swift */; }; + C1935A102C88D131001AD72D /* AutofillSurveyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1935A0F2C88D131001AD72D /* AutofillSurveyManager.swift */; }; + C1935A122C88D1D8001AD72D /* AutofillHeaderViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1935A112C88D1D8001AD72D /* AutofillHeaderViewFactory.swift */; }; + C1935A222C89CA9F001AD72D /* AutofillSurveyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1935A212C89CA9F001AD72D /* AutofillSurveyManagerTests.swift */; }; + C1935A242C89CC6D001AD72D /* AutofillHeaderViewFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1935A232C89CC6D001AD72D /* AutofillHeaderViewFactoryTests.swift */; }; C1963863283794A000298D4D /* BookmarksCachingSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1963862283794A000298D4D /* BookmarksCachingSearch.swift */; }; C1B7B51C28941E980098FD6A /* HomeMessageViewModelBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B7B51B28941E980098FD6A /* HomeMessageViewModelBuilder.swift */; }; C1B7B52528941F2A0098FD6A /* RemoteMessagingClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B7B52128941F2A0098FD6A /* RemoteMessagingClient.swift */; }; @@ -2619,6 +2624,11 @@ C185ED652BD43A5500BAE9DC /* MockDDGSyncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDDGSyncing.swift; sourceTree = ""; }; C18ED4392AB6F77600BF3805 /* AutofillSettingsEnableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillSettingsEnableFooterView.swift; sourceTree = ""; }; C18ED43B2AB8364400BF3805 /* FileTextPreviewDebugViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileTextPreviewDebugViewController.swift; sourceTree = ""; }; + C1935A0D2C88D11D001AD72D /* AutofillSurveyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillSurveyView.swift; sourceTree = ""; }; + C1935A0F2C88D131001AD72D /* AutofillSurveyManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillSurveyManager.swift; sourceTree = ""; }; + C1935A112C88D1D8001AD72D /* AutofillHeaderViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillHeaderViewFactory.swift; sourceTree = ""; }; + C1935A212C89CA9F001AD72D /* AutofillSurveyManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillSurveyManagerTests.swift; sourceTree = ""; }; + C1935A232C89CC6D001AD72D /* AutofillHeaderViewFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillHeaderViewFactoryTests.swift; sourceTree = ""; }; C1963862283794A000298D4D /* BookmarksCachingSearch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarksCachingSearch.swift; sourceTree = ""; }; C1B0F6412AB08BE9001EAF05 /* MockPrivacyConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPrivacyConfiguration.swift; sourceTree = ""; }; C1B7B51B28941E980098FD6A /* HomeMessageViewModelBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeMessageViewModelBuilder.swift; sourceTree = ""; }; @@ -3509,6 +3519,7 @@ 319A37132829A5450079FBCE /* Table */ = { isa = PBXGroup; children = ( + C1935A0C2C88D101001AD72D /* Survey */, 31CC224828369B38001654A4 /* AutofillLoginSettingsListViewController.swift */, 319A37142829A55F0079FBCE /* AutofillListItemTableViewCell.swift */, 310ECFDC282A8BB0005029B3 /* EnableAutofillSettingsTableViewCell.swift */, @@ -3520,6 +3531,7 @@ C1B924B62ACD6E6800EE7B06 /* AutofillNeverSavedTableViewCell.swift */, C1836CE02C359EC90016D057 /* AutofillBreakageReportCellContentView.swift */, C1836CE42C35A0EA0016D057 /* AutofillBreakageReportTableViewCell.swift */, + C1935A112C88D1D8001AD72D /* AutofillHeaderViewFactory.swift */, ); name = Table; sourceTree = ""; @@ -4978,6 +4990,49 @@ name = Import; sourceTree = ""; }; + C1935A0C2C88D101001AD72D /* Survey */ = { + isa = PBXGroup; + children = ( + C1935A0F2C88D131001AD72D /* AutofillSurveyManager.swift */, + C1935A0D2C88D11D001AD72D /* AutofillSurveyView.swift */, + ); + name = Survey; + sourceTree = ""; + }; + C1935A1D2C89CA4B001AD72D /* Management */ = { + isa = PBXGroup; + children = ( + C1935A1E2C89CA53001AD72D /* List */, + F40F843528C938370081AE75 /* AutofillLoginListViewModelTests.swift */, + ); + name = Management; + sourceTree = ""; + }; + C1935A1E2C89CA53001AD72D /* List */ = { + isa = PBXGroup; + children = ( + C1935A1F2C89CA5A001AD72D /* Table */, + ); + name = List; + sourceTree = ""; + }; + C1935A1F2C89CA5A001AD72D /* Table */ = { + isa = PBXGroup; + children = ( + C1935A202C89CA5F001AD72D /* Survey */, + C1935A232C89CC6D001AD72D /* AutofillHeaderViewFactoryTests.swift */, + ); + name = Table; + sourceTree = ""; + }; + C1935A202C89CA5F001AD72D /* Survey */ = { + isa = PBXGroup; + children = ( + C1935A212C89CA9F001AD72D /* AutofillSurveyManagerTests.swift */, + ); + name = Survey; + sourceTree = ""; + }; C1AFFC4B2B8773060060448E /* AuthConfirmation */ = { isa = PBXGroup; children = ( @@ -6162,9 +6217,9 @@ F40F843228C92B1C0081AE75 /* Autofill */ = { isa = PBXGroup; children = ( + C1935A1D2C89CA4B001AD72D /* Management */, C185ED622BD4388F00BAE9DC /* Import */, C1BF0BA629B63E0400482B73 /* AutofillLoginUI */, - F40F843528C938370081AE75 /* AutofillLoginListViewModelTests.swift */, C1D21E2E293A599C006E5A05 /* AutofillLoginSessionTests.swift */, C1CDA31D2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift */, ); @@ -7295,6 +7350,7 @@ 1E8AD1C927BFAD1500ABA377 /* DirectoryMonitor.swift in Sources */, 377D80222AB48554002AF251 /* FavoritesDisplayModeSyncHandler.swift in Sources */, 1E8AD1D127C000AB00ABA377 /* OngoingDownloadRow.swift in Sources */, + C1935A0E2C88D11D001AD72D /* AutofillSurveyView.swift in Sources */, 1DEAADF02BA46E0700E25A97 /* PrivateSearchView.swift in Sources */, 85058366219AE9EA00ED4EDB /* HomePageConfiguration.swift in Sources */, 1E9529A12C4E748B006E80D4 /* UINavigationControllerExtension.swift in Sources */, @@ -7315,6 +7371,7 @@ C1641EAF2BC2F5140012607A /* ImportPasswordsViewController.swift in Sources */, D63FF8982C1B6A45006DE24D /* DuckPlayer.swift in Sources */, 85B9CB8921AEBDD5009001F1 /* FavoriteHomeCell.swift in Sources */, + C1935A102C88D131001AD72D /* AutofillSurveyManager.swift in Sources */, 98999D5922FDA41500CBBE1B /* BasicAuthenticationAlert.swift in Sources */, C13B32D22A0E750700A59236 /* AutofillSettingStatus.swift in Sources */, 1DDF40202BA049FA006850D9 /* SettingsRootView.swift in Sources */, @@ -7370,6 +7427,7 @@ F1617C151E57336D00DEDCAF /* TabManager.swift in Sources */, 85449EF523FDA02800512AAF /* KeyboardSettingsViewController.swift in Sources */, 85C11E4C2090888C00BFFEB4 /* HomeRowReminder.swift in Sources */, + C1935A122C88D1D8001AD72D /* AutofillHeaderViewFactory.swift in Sources */, 31B2F11F287846320040427A /* NoMicPermissionAlert.swift in Sources */, 310C4B45281B5A9A00BA79A9 /* AutofillLoginDetailsView.swift in Sources */, 6F9FFE2D2C57AE8F00A238BE /* NewTabPageShortcutsSettingsModel.swift in Sources */, @@ -7749,6 +7807,7 @@ 8598F67B2405EB8D00FBC70C /* KeyboardSettingsTests.swift in Sources */, 98AAF8E4292EB46000DBDF06 /* BookmarksMigrationTests.swift in Sources */, 85D2187224BF24F2004373D2 /* NotFoundCachingDownloaderTests.swift in Sources */, + C1935A242C89CC6D001AD72D /* AutofillHeaderViewFactoryTests.swift in Sources */, C111B26927F579EF006558B1 /* BookmarkOrFolderTests.swift in Sources */, 6F7FB8E72C66197E00867DA7 /* NewTabPageSectionsSettingsModelTests.swift in Sources */, 851CD674244D7E6000331B98 /* UserDefaultsExtension.swift in Sources */, @@ -7829,6 +7888,7 @@ CBCCF96828885DEE006F4A71 /* AppPrivacyConfigurationTests.swift in Sources */, 6F40D15E2C34436500BF22F0 /* HomePageDisplayDailyPixelBucketTests.swift in Sources */, 310742AB2848E6FD0012660B /* BackForwardMenuHistoryItemURLSanitizerTests.swift in Sources */, + C1935A222C89CA9F001AD72D /* AutofillSurveyManagerTests.swift in Sources */, 22CB1ED8203DDD2C00D2C724 /* AppDeepLinksTests.swift in Sources */, 9847C00527A41A0A00DB07AA /* WebViewTestHelper.swift in Sources */, 3170048227A9504F00C03F35 /* DownloadMocks.swift in Sources */, diff --git a/DuckDuckGo/Assets.xcassets/Passwords-DDG-96x96.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Passwords-DDG-96x96.imageset/Contents.json new file mode 100644 index 0000000000..608aeb0457 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Passwords-DDG-96x96.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Passwords-DDG-96x96.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Passwords-DDG-96x96.imageset/Passwords-DDG-96x96.svg b/DuckDuckGo/Assets.xcassets/Passwords-DDG-96x96.imageset/Passwords-DDG-96x96.svg new file mode 100644 index 0000000000..2c92f4ff23 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Passwords-DDG-96x96.imageset/Passwords-DDG-96x96.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/AutofillDebugViewController.swift b/DuckDuckGo/AutofillDebugViewController.swift index 78ebb76778..dad15dbfa9 100644 --- a/DuckDuckGo/AutofillDebugViewController.swift +++ b/DuckDuckGo/AutofillDebugViewController.swift @@ -33,6 +33,7 @@ class AutofillDebugViewController: UITableViewController { case resetAutofillData = 204 case addAutofillData = 205 case resetAutofillBrokenReports = 206 + case resetAutofillSurveys = 207 } let defaults = AppUserDefaults() @@ -87,6 +88,11 @@ class AutofillDebugViewController: UITableViewController { let expiryDate = Calendar.current.date(byAdding: .day, value: 60, to: Date())! _ = reporter.persistencyManager.removeExpiredItems(currentDate: expiryDate) ActionMessageView.present(message: "Autofill Broken Reports reset") + } else if cell.tag == Row.resetAutofillSurveys.rawValue { + tableView.deselectRow(at: indexPath, animated: true) + let autofillSurveyManager = AutofillSurveyManager() + autofillSurveyManager.resetSurveys() + ActionMessageView.present(message: "Autofill Surveys reset") } } } @@ -114,7 +120,7 @@ class AutofillDebugViewController: UITableViewController { let secureVault = try? AutofillSecureVaultFactory.makeVault(reporter: SecureVaultReporter()) for i in 1...count { - let account = SecureVaultModels.WebsiteAccount(title: "", username: "Dax \(i)", domain: "https://fill.dev", notes: "") + let account = SecureVaultModels.WebsiteAccount(title: "", username: "Dax \(i)", domain: "fill.dev", notes: "") let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: "password".data(using: .utf8)) do { _ = try secureVault?.storeWebsiteCredentials(credentials) diff --git a/DuckDuckGo/AutofillHeaderViewFactory.swift b/DuckDuckGo/AutofillHeaderViewFactory.swift new file mode 100644 index 0000000000..dd5eb7322e --- /dev/null +++ b/DuckDuckGo/AutofillHeaderViewFactory.swift @@ -0,0 +1,92 @@ +// +// AutofillHeaderViewFactory.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 UIKit +import SwiftUI +import Core + +protocol AutofillHeaderViewDelegate: AnyObject { + func handlePrimaryAction(for headerType: AutofillHeaderViewFactory.ViewType) + func handleDismissAction(for headerType: AutofillHeaderViewFactory.ViewType) +} + +protocol AutofillHeaderViewFactoryProtocol: AnyObject { + var delegate: AutofillHeaderViewDelegate? { get set } + + func makeHeaderView(for type: AutofillHeaderViewFactory.ViewType) -> UIViewController +} + +final class AutofillHeaderViewFactory: AutofillHeaderViewFactoryProtocol { + + weak var delegate: AutofillHeaderViewDelegate? + + enum ViewType { + case syncPromo(SyncPromoManager.Touchpoint) + case survey(AutofillSurveyManager.AutofillSurvey) + } + + init(delegate: AutofillHeaderViewDelegate?) { + self.delegate = delegate + } + + func makeHeaderView(for type: ViewType) -> UIViewController { + switch type { + case .syncPromo(let touchpointType): + return makeSyncPromoView(touchpointType: touchpointType) + case .survey(let survey): + return makeSurveyView(survey: survey) + } + } + + private func makeSyncPromoView(touchpointType: SyncPromoManager.Touchpoint) -> UIHostingController { + let headerView = SyncPromoView(viewModel: SyncPromoViewModel( + touchpointType: touchpointType, + primaryButtonAction: { [weak delegate] in + delegate?.handlePrimaryAction(for: .syncPromo(touchpointType)) + }, + dismissButtonAction: { [weak delegate] in + delegate?.handleDismissAction(for: .syncPromo(touchpointType)) + } + )) + + Pixel.fire(.syncPromoDisplayed, withAdditionalParameters: ["source": touchpointType.rawValue]) + + let hostingController = UIHostingController(rootView: headerView) + hostingController.view.backgroundColor = .clear + return hostingController + } + + private func makeSurveyView(survey: AutofillSurveyManager.AutofillSurvey) -> UIHostingController { + let headerView = AutofillSurveyView( + primaryButtonAction: { [weak delegate] in + delegate?.handlePrimaryAction(for: .survey(survey)) + }, + dismissButtonAction: { [weak delegate] in + delegate?.handleDismissAction(for: .survey(survey)) + } + ) + + Pixel.fire(pixel: .autofillManagementScreenVisitSurveyAvailable) + + let hostingController = UIHostingController(rootView: headerView) + hostingController.view.backgroundColor = .clear + return hostingController + } +} diff --git a/DuckDuckGo/AutofillLoginListViewModel.swift b/DuckDuckGo/AutofillLoginListViewModel.swift index 77ac3f4bb1..5c2ff7a615 100644 --- a/DuckDuckGo/AutofillLoginListViewModel.swift +++ b/DuckDuckGo/AutofillLoginListViewModel.swift @@ -95,6 +95,7 @@ final class AutofillLoginListViewModel: ObservableObject { private let autofillDomainNameUrlMatcher = AutofillDomainNameUrlMatcher() private let autofillDomainNameUrlSort = AutofillDomainNameUrlSort() private let syncService: DDGSyncing + private let locale: Locale private var showBreakageReporter: Bool = false private lazy var reporterDateFormatter = { @@ -110,6 +111,8 @@ final class AutofillLoginListViewModel: ObservableObject { private lazy var syncPromoManager: SyncPromoManaging = SyncPromoManager(syncService: syncService) + private lazy var autofillSurveyManager: AutofillSurveyManaging = AutofillSurveyManager() + internal lazy var breakageReporter = BrokenSiteReporter(pixelHandler: { [weak self] _ in if let currentTabUid = self?.currentTabUid { NotificationCenter.default.post(name: .autofillFailureReport, object: self, userInfo: [UserInfoKeys.tabUid: currentTabUid]) @@ -118,7 +121,7 @@ final class AutofillLoginListViewModel: ObservableObject { self?.showBreakageReporter = false }, keyValueStoring: keyValueStore, storageConfiguration: .autofillConfig) - @Published private (set) var viewState: AutofillLoginListViewModel.ViewState = .authLocked + @Published private(set) var viewState: AutofillLoginListViewModel.ViewState = .authLocked @Published private(set) var sections = [AutofillLoginListSectionType]() { didSet { updateViewState() @@ -156,7 +159,8 @@ final class AutofillLoginListViewModel: ObservableObject { autofillNeverPromptWebsitesManager: AutofillNeverPromptWebsitesManager = AppDependencyProvider.shared.autofillNeverPromptWebsitesManager, privacyConfig: PrivacyConfiguration = ContentBlocking.shared.privacyConfigurationManager.privacyConfig, keyValueStore: KeyValueStoringDictionaryRepresentable = UserDefaults.standard, - syncService: DDGSyncing) { + syncService: DDGSyncing, + locale: Locale = Locale.current) { self.appSettings = appSettings self.tld = tld self.secureVault = secureVault @@ -166,6 +170,7 @@ final class AutofillLoginListViewModel: ObservableObject { self.privacyConfig = privacyConfig self.keyValueStore = keyValueStore self.syncService = syncService + self.locale = locale if let count = getAccountsCount() { authenticationNotRequired = count == 0 || AppDependencyProvider.shared.autofillLoginSession.isSessionValid @@ -329,6 +334,24 @@ final class AutofillLoginListViewModel: ObservableObject { syncPromoManager.dismissPromoFor(.passwords) } + func getSurveyToPresent() -> AutofillSurveyManager.AutofillSurvey? { + guard locale.isEnglishLanguage, + viewState == .showItems || viewState == .empty, + !isEditing, + privacyConfig.isEnabled(featureKey: .autofillSurveys) else { + return nil + } + return autofillSurveyManager.surveyToPresent(settings: privacyConfig.settings(for: .autofillSurveys)) + } + + func surveyUrl(survey: String) -> URL? { + return autofillSurveyManager.buildSurveyUrl(survey, accountsCount: accountsCount) + } + + func dismissSurvey(id: String) { + autofillSurveyManager.markSurveyAsCompleted(id: id) + } + // MARK: Private Methods private func saveReport(for currentTabUrl: URL) { diff --git a/DuckDuckGo/AutofillLoginSettingsListViewController.swift b/DuckDuckGo/AutofillLoginSettingsListViewController.swift index b3823fd806..65a3c5c59a 100644 --- a/DuckDuckGo/AutofillLoginSettingsListViewController.swift +++ b/DuckDuckGo/AutofillLoginSettingsListViewController.swift @@ -149,21 +149,11 @@ final class AutofillLoginSettingsListViewController: UIViewController { return tableView }() - private lazy var syncPromoViewHostingController: UIHostingController = { - let headerView = SyncPromoView(viewModel: SyncPromoViewModel(touchpointType: .passwords, primaryButtonAction: { [weak self] in - self?.segueToSync(source: "promotion_passwords") - Pixel.fire(.syncPromoConfirmed, withAdditionalParameters: ["source": SyncPromoManager.Touchpoint.passwords.rawValue]) - }, dismissButtonAction: { [weak self] in - self?.viewModel.dismissSyncPromo() - self?.updateTableHeaderView() - })) - - Pixel.fire(.syncPromoDisplayed, withAdditionalParameters: ["source": SyncPromoManager.Touchpoint.passwords.rawValue]) - - let hostingController = UIHostingController(rootView: headerView) - hostingController.view.backgroundColor = .clear - return hostingController - }() + private lazy var headerViewFactory: AutofillHeaderViewFactoryProtocol = AutofillHeaderViewFactory(delegate: self) + private var currentHeaderHostingController: UIViewController? + + // This is used to prevent the Sync Promo from being displayed immediately after the Survey is dismissed + private var surveyPromptPresented: Bool = false private lazy var lockedViewBottomConstraint: NSLayoutConstraint = { NSLayoutConstraint(item: tableView, @@ -672,22 +662,67 @@ final class AutofillLoginSettingsListViewController: UIViewController { } private func updateTableHeaderView() { - if viewModel.shouldShowSyncPromo() { - guard tableView.frame != .zero, tableView.tableHeaderView != syncPromoViewHostingController.view else { - return + guard tableView.frame != .zero else { + return + } + + if let survey = viewModel.getSurveyToPresent() { + if shouldUpdateHeaderView(for: .survey(survey)) { + configureTableHeaderView(for: .survey(survey)) + surveyPromptPresented = true + } + return + } + + if viewModel.shouldShowSyncPromo() && !surveyPromptPresented { + if shouldUpdateHeaderView(for: .syncPromo(.passwords)) { + configureTableHeaderView(for: .syncPromo(.passwords)) } + return + } - addChild(syncPromoViewHostingController) + // No header view is needed, clear the table header + clearTableHeaderView() + } - let syncPromoViewHeight = syncPromoViewHostingController.view.sizeThatFits(CGSize(width: tableView.bounds.width - tableView.layoutMargins.left - tableView.layoutMargins.right, height: CGFloat.greatestFiniteMagnitude)).height - syncPromoViewHostingController.view.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: syncPromoViewHeight) - tableView.tableHeaderView = syncPromoViewHostingController.view + private func shouldUpdateHeaderView(for type: AutofillHeaderViewFactory.ViewType) -> Bool { + if let currentHeaderView = tableView.tableHeaderView, + let headerView = currentHeaderHostingController?.view, + currentHeaderView == headerView { + return false + } + return true + } - syncPromoViewHostingController.didMove(toParent: self) - } else { - guard tableView.tableHeaderView != nil else { - return + private func configureTableHeaderView(for type: AutofillHeaderViewFactory.ViewType) { + switch type { + case .survey(let survey): + currentHeaderHostingController = headerViewFactory.makeHeaderView(for: .survey(survey)) + if let hostingController = currentHeaderHostingController as? UIHostingController { + setupTableHeaderView(with: hostingController) + } + case .syncPromo(let promoType): + currentHeaderHostingController = headerViewFactory.makeHeaderView(for: .syncPromo(promoType)) + if let hostingController = currentHeaderHostingController as? UIHostingController { + setupTableHeaderView(with: hostingController) } + } + } + + private func setupTableHeaderView(with hostingController: UIViewController) { + addChild(hostingController) + + let viewWidth = tableView.bounds.width - tableView.layoutMargins.left - tableView.layoutMargins.right + let viewHeight = hostingController.view.sizeThatFits(CGSize(width: viewWidth, height: CGFloat.greatestFiniteMagnitude)).height + + hostingController.view.frame = CGRect(x: 0, y: 0, width: viewWidth, height: viewHeight) + tableView.tableHeaderView = hostingController.view + + hostingController.didMove(toParent: self) + } + + private func clearTableHeaderView() { + if tableView.tableHeaderView != nil { tableView.tableHeaderView = nil } } @@ -1115,6 +1150,38 @@ extension AutofillLoginSettingsListViewController { } } +// MARK: AutofillHeaderViewDelegate + +extension AutofillLoginSettingsListViewController: AutofillHeaderViewDelegate { + + func handlePrimaryAction(for headerType: AutofillHeaderViewFactory.ViewType) { + switch headerType { + case .survey(let survey): + if let surveyURL = viewModel.surveyUrl(survey: survey.url) { + LaunchTabNotification.postLaunchTabNotification(urlString: surveyURL.absoluteString) + self.dismiss(animated: true) + } + viewModel.dismissSurvey(id: survey.id) + case .syncPromo(let touchpoint): + segueToSync(source: "promotion_passwords") + Pixel.fire(.syncPromoConfirmed, withAdditionalParameters: ["source": touchpoint.rawValue]) + } + } + + func handleDismissAction(for headerType: AutofillHeaderViewFactory.ViewType) { + defer { + updateTableHeaderView() + } + + switch headerType { + case .survey(let survey): + viewModel.dismissSurvey(id: survey.id) + case .syncPromo: + viewModel.dismissSyncPromo() + } + } +} + extension NSNotification.Name { static let autofillFailureReport: NSNotification.Name = Notification.Name(rawValue: "com.duckduckgo.notification.autofillFailureReport") } diff --git a/DuckDuckGo/AutofillSurveyManager.swift b/DuckDuckGo/AutofillSurveyManager.swift new file mode 100644 index 0000000000..c8b2d2581b --- /dev/null +++ b/DuckDuckGo/AutofillSurveyManager.swift @@ -0,0 +1,126 @@ +// +// AutofillSurveyManager.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 BrowserServicesKit +import Core +import RemoteMessaging + +protocol AutofillSurveyManaging { + func surveyToPresent(settings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings) -> AutofillSurveyManager.AutofillSurvey? + func markSurveyAsCompleted(id: String) + func resetSurveys() + func buildSurveyUrl(_ url: String, accountsCount: Int) -> URL? +} + +final class AutofillSurveyManager: AutofillSurveyManaging { + + struct AutofillSurvey { + let id: String + let url: String + } + + @UserDefaultsWrapper(key: .autofillSurveysCompleted, defaultValue: []) + private var autofillSurveysCompleted: [String] + + private enum BucketName: String { + case none + case few + case some + case many + case lots + } + + private enum Constants { + static let surveysSettingsKey = "surveys" + static let surveysIdSettingsKey = "id" + static let surveysUrlSettingsKey = "url" + static let savedPasswordsQueryParam = "saved_passwords" + static let listQueryParam = "list" + } + + func surveyToPresent(settings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings) -> AutofillSurvey? { + guard let surveys = settings[Constants.surveysSettingsKey] as? [[String: Any]] else { + return nil + } + + for survey in surveys { + guard let id = survey[Constants.surveysIdSettingsKey] as? String, + let url = survey[Constants.surveysUrlSettingsKey] as? String, + !hasCompletedSurvey(id: id) else { + continue + } + return AutofillSurvey(id: id, url: url) + } + + return nil + } + + func markSurveyAsCompleted(id: String) { + autofillSurveysCompleted.append(id) + } + + func resetSurveys() { + autofillSurveysCompleted.removeAll() + } + + func buildSurveyUrl(_ url: String, accountsCount: Int) -> URL? { + guard let surveyURL = URL(string: url) else { + return nil + } + + let surveyURLBuilder = DefaultRemoteMessagingSurveyURLBuilder(statisticsStore: StatisticsUserDefaults(), + vpnActivationDateStore: DefaultVPNActivationDateStore(), + subscription: nil) + let url = surveyURLBuilder.add(parameters: [.appVersion, .atb, .atbVariant, .daysInstalled, .hardwareModel, .osVersion, .vpnFirstUsed, .vpnLastUsed], to: surveyURL) + return addPasswordsCountSurveyParameter(to: url, accountsCount: accountsCount) + } + + private func hasCompletedSurvey(id: String) -> Bool { + autofillSurveysCompleted.contains(id) + } + + private func addPasswordsCountSurveyParameter(to surveyURL: URL, accountsCount: Int) -> URL { + guard var components = URLComponents(string: surveyURL.absoluteString) else { + assertionFailure("Could not build URL components from survey URL") + return surveyURL + } + + var queryItems = components.queryItems ?? [] + queryItems.append(URLQueryItem(name: Constants.savedPasswordsQueryParam, + value: bucketNameFrom(count: accountsCount))) + components.queryItems = queryItems + + return components.url ?? surveyURL + } + + private func bucketNameFrom(count: Int) -> String { + if count == 0 { + return BucketName.none.rawValue + } else if count < 4 { + return BucketName.few.rawValue + } else if count < 11 { + return BucketName.some.rawValue + } else if count < 50 { + return BucketName.many.rawValue + } else { + return BucketName.lots.rawValue + } + } +} diff --git a/DuckDuckGo/AutofillSurveyView.swift b/DuckDuckGo/AutofillSurveyView.swift new file mode 100644 index 0000000000..d61bc661e1 --- /dev/null +++ b/DuckDuckGo/AutofillSurveyView.swift @@ -0,0 +1,100 @@ +// +// AutofillSurveyView.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 Core +import DesignResourcesKit +import DuckUI +import SwiftUI + +struct AutofillSurveyView: View { + var primaryButtonAction: (() -> Void)? + var dismissButtonAction: (() -> Void)? + + var body: some View { + ZStack(alignment: .topTrailing) { + VStack(spacing: 8) { + Group { + Image(.passwordsDDG96X96) + .resizable() + .frame(width: 50, height: 50, alignment: .center) + + Text(verbatim: "Help us improve!") + .daxHeadline() + .foregroundColor(Color(designSystemColor: .textPrimary)) + .padding(.top, 8) + .frame(maxWidth: .infinity) + + Text(verbatim: "We want to make using passwords in DuckDuckGo better.") + .daxBodyRegular() + .foregroundColor(Color(designSystemColor: .textPrimary)) + .padding(.top, 4) + + Button { + primaryButtonAction?() + } label: { + HStack { + Text(verbatim: "Take Survey") + .daxButton() + } + } + .buttonStyle(PrimaryButtonStyle(compact: true, fullWidth: false)) + .padding(.top, 8) + } + .padding(.horizontal, 24) + } + .multilineTextAlignment(.center) + .padding(.vertical) + .padding(.horizontal, 8) + + VStack { + HStack { + Spacer() + Button { + dismissButtonAction?() + } label: { + Image(.close24) + .foregroundColor(.primary) + } + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + .padding(0) + } + } + .alignmentGuide(.top) { dimension in + dimension[.top] + } + } + .background(RoundedRectangle(cornerRadius: 8.0) + .foregroundColor(Color(designSystemColor: .surface)) + ) + .padding([.horizontal, .top], 20) + .padding(.bottom, 30) + } + +} + +#Preview("Light") { + AutofillSurveyView() + .preferredColorScheme(.light) +} + +#Preview("Dark") { + AutofillSurveyView() + .preferredColorScheme(.dark) +} diff --git a/DuckDuckGo/Debug.storyboard b/DuckDuckGo/Debug.storyboard index c38326876d..bc27b68634 100644 --- a/DuckDuckGo/Debug.storyboard +++ b/DuckDuckGo/Debug.storyboard @@ -491,12 +491,21 @@ + + + + + + + + + - + @@ -505,7 +514,7 @@ - + diff --git a/DuckDuckGoTests/AutofillHeaderViewFactoryTests.swift b/DuckDuckGoTests/AutofillHeaderViewFactoryTests.swift new file mode 100644 index 0000000000..b2f67e28b8 --- /dev/null +++ b/DuckDuckGoTests/AutofillHeaderViewFactoryTests.swift @@ -0,0 +1,144 @@ +// +// AutofillHeaderViewFactoryTests.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 SwiftUI +@testable import DuckDuckGo + +class MockAutofillHeaderViewDelegate: AutofillHeaderViewDelegate { + var didHandlePrimaryAction = false + var didHandleDismissAction = false + var lastHandledHeaderType: AutofillHeaderViewFactory.ViewType? + + func handlePrimaryAction(for headerType: AutofillHeaderViewFactory.ViewType) { + didHandlePrimaryAction = true + lastHandledHeaderType = headerType + } + + func handleDismissAction(for headerType: AutofillHeaderViewFactory.ViewType) { + didHandleDismissAction = true + lastHandledHeaderType = headerType + } +} + +final class AutofillHeaderViewFactoryTests: XCTestCase { + + var factory: AutofillHeaderViewFactory! + var mockDelegate: MockAutofillHeaderViewDelegate! + + override func setUpWithError() throws { + try super.setUpWithError() + + mockDelegate = MockAutofillHeaderViewDelegate() + factory = AutofillHeaderViewFactory(delegate: mockDelegate) + } + + override func tearDownWithError() throws { + factory = nil + mockDelegate = nil + + try super.tearDownWithError() + } + + func testWhenMakeHeaderViewForSyncPromoThenSyncPromoViewIsReturned() { + let viewController = factory.makeHeaderView(for: .syncPromo(.passwords)) + XCTAssertTrue(viewController is UIHostingController) + if let hostingController = viewController as? UIHostingController { + XCTAssertNotNil(hostingController.rootView) + } + } + + func testWhenMakeHeaderViewForSurveyThenAutofillSurveyViewIsReturned() { + let survey = AutofillSurveyManager.AutofillSurvey(id: "testSurvey", url: "https://example.com") + let viewController = factory.makeHeaderView(for: .survey(survey)) + + XCTAssertTrue(viewController is UIHostingController) + if let hostingController = viewController as? UIHostingController { + XCTAssertNotNil(hostingController.rootView) + } + } + + func testWhenSyncPromoPrimaryButtonActionIsCalledThenDelegateHandlePrimaryActionIsCalled() throws { + let touchpoint = SyncPromoManager.Touchpoint.passwords + let viewController = try XCTUnwrap(factory.makeHeaderView(for: .syncPromo(touchpoint)) as? UIHostingController, "Expected a UIHostingController") + + let primaryButtonAction = try XCTUnwrap(viewController.rootView.viewModel.primaryButtonAction, "Primary button action should not be nil") + primaryButtonAction() + + XCTAssertTrue(mockDelegate.didHandlePrimaryAction) + if case .syncPromo(let receivedTouchpoint) = mockDelegate.lastHandledHeaderType { + XCTAssertEqual(receivedTouchpoint, touchpoint) + } else { + XCTFail("Expected .syncPromo ViewType with touchpoint \(touchpoint)") + } + } + + func testWhenSyncPromoDismissButtonActionIsCalledThenDelegateHandleDismissActionIsCalled() throws { + let touchpoint = SyncPromoManager.Touchpoint.passwords + + let viewController = try XCTUnwrap(factory.makeHeaderView(for: .syncPromo(touchpoint)) as? UIHostingController, "Expected a UIHostingController") + + let dismissButtonAction = try XCTUnwrap(viewController.rootView.viewModel.dismissButtonAction, "Dismiss button action should not be nil") + dismissButtonAction() + + XCTAssertTrue(mockDelegate.didHandleDismissAction) + + if case .syncPromo(let receivedTouchpoint) = mockDelegate.lastHandledHeaderType { + XCTAssertEqual(receivedTouchpoint, touchpoint) + } else { + XCTFail("Expected .syncPromo ViewType with touchpoint \(touchpoint)") + } + } + + func testWhenSurveyPrimaryButtonActionIsCalledThenDelegateHandlePrimaryActionIsCalled() throws { + let survey = AutofillSurveyManager.AutofillSurvey(id: "testSurvey", url: "https://example.com") + + let viewController = try XCTUnwrap(factory.makeHeaderView(for: .survey(survey)) as? UIHostingController, "Expected a UIHostingController") + + let primaryButtonAction = try XCTUnwrap(viewController.rootView.primaryButtonAction, "Primary button action should not be nil") + primaryButtonAction() + + XCTAssertTrue(mockDelegate.didHandlePrimaryAction) + + if case .survey(let receivedSurvey) = mockDelegate.lastHandledHeaderType { + XCTAssertEqual(receivedSurvey.id, survey.id) + XCTAssertEqual(receivedSurvey.url, survey.url) + } else { + XCTFail("Expected .survey ViewType with survey \(survey)") + } + } + + func testWhenSurveyDismissButtonActionIsCalledThenDelegateHandleDismissActionIsCalled() throws { + let survey = AutofillSurveyManager.AutofillSurvey(id: "testSurvey", url: "https://example.com") + + let viewController = try XCTUnwrap(factory.makeHeaderView(for: .survey(survey)) as? UIHostingController, "Expected a UIHostingController") + + let dismissButtonAction = try XCTUnwrap(viewController.rootView.dismissButtonAction, "Dismiss button action should not be nil") + dismissButtonAction() + + XCTAssertTrue(mockDelegate.didHandleDismissAction) + + if case .survey(let receivedSurvey) = mockDelegate.lastHandledHeaderType { + XCTAssertEqual(receivedSurvey.id, survey.id) + XCTAssertEqual(receivedSurvey.url, survey.url) + } else { + XCTFail("Expected .survey ViewType with survey \(survey)") + } + } +} diff --git a/DuckDuckGoTests/AutofillLoginListViewModelTests.swift b/DuckDuckGoTests/AutofillLoginListViewModelTests.swift index f4f0880d8e..0ff5c5ac3a 100644 --- a/DuckDuckGoTests/AutofillLoginListViewModelTests.swift +++ b/DuckDuckGoTests/AutofillLoginListViewModelTests.swift @@ -50,6 +50,17 @@ class AutofillLoginListViewModelTests: XCTestCase { } ] }, + "autofillSurveys": { + "state": "enabled", + "settings": { + "surveys": [ + { + "id": "123", + "url": "https://asurveyurl.com" + } + ] + }, + }, }, "unprotectedTemporary": [] } @@ -65,6 +76,17 @@ class AutofillLoginListViewModelTests: XCTestCase { }, "exceptions": [] }, + "autofillSurveys": { + "state": "disabled", + "settings": { + "surveys": [ + { + "id": "240900", + "url": "https://asurveyurl.com" + } + ] + }, + }, }, "unprotectedTemporary": [] } @@ -72,6 +94,7 @@ class AutofillLoginListViewModelTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() + setupUserDefault(with: #file) manager = AutofillNeverPromptWebsitesManager(secureVault: vault) syncService = MockDDGSyncing(authState: .inactive, scheduler: CapturingScheduler(), isSyncInProgress: false) } @@ -492,7 +515,7 @@ class AutofillLoginListViewModelTests: XCTestCase { let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, - currentTabUrl: URL(string: "https://\(testDomain)"), + currentTabUrl: currentTabUrl, currentTabUid: "1", autofillNeverPromptWebsitesManager: manager, privacyConfig: makePrivacyConfig(from: configEnabled), @@ -529,6 +552,60 @@ class AutofillLoginListViewModelTests: XCTestCase { XCTAssertTrue(model.shouldShowBreakageReporter()) } + + func testWhenLocaleIsNotEnglishThenNoSurveyIsReturned() { + let nonEnglishLocale = Locale(identifier: "es") + let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService, locale: nonEnglishLocale) + + XCTAssertNil(model.getSurveyToPresent()) + } + + func testWhenViewStateIsIneligibleThenNoSurveyIsReturned() throws { + vault.storedAccounts = [ + SecureVaultModels.WebsiteAccount(id: "1", title: nil, username: "", domain: "testsite.com", created: Date(), lastUpdated: Date()), + SecureVaultModels.WebsiteAccount(id: "2", title: nil, username: "", domain: "testsite.com", created: Date(), lastUpdated: Date()) + ] + for account in vault.storedAccounts { + _ = try vault.storeWebsiteCredentials(SecureVaultModels.WebsiteCredentials(account: account, password: nil)) + } + let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) + + XCTAssertNil(model.getSurveyToPresent()) + } + + func testWhenIsEditingThenNoSurveyIsReturned() { + let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) + model.isEditing = true + + XCTAssertNil(model.getSurveyToPresent()) + } + + func testWhenSurveyConfigIsDisabledThenNoSurveyIsReturned() { + let model = AutofillLoginListViewModel(appSettings: appSettings, + tld: tld, + secureVault: vault, + privacyConfig: makePrivacyConfig(from: configDisabled), + syncService: syncService) + + XCTAssertNil(model.getSurveyToPresent()) + } + + func testWhenAllConditionsAreMetThenSurveyIsReturnedAndWhenDismissedNotSurveyIsReturned() { + let model = AutofillLoginListViewModel(appSettings: appSettings, + tld: tld, + secureVault: vault, + privacyConfig: makePrivacyConfig(from: configEnabled), + syncService: syncService) + let survey = model.getSurveyToPresent() + XCTAssertNotNil(survey) + XCTAssertEqual(survey?.id, "123") + XCTAssertEqual(survey?.url, "https://asurveyurl.com") + + model.dismissSurvey(id: "123") + + XCTAssertNil(model.getSurveyToPresent()) + } + } class AutofillLoginListSectionTypeTests: XCTestCase { diff --git a/DuckDuckGoTests/AutofillSurveyManagerTests.swift b/DuckDuckGoTests/AutofillSurveyManagerTests.swift new file mode 100644 index 0000000000..61f8e350b7 --- /dev/null +++ b/DuckDuckGoTests/AutofillSurveyManagerTests.swift @@ -0,0 +1,118 @@ +// +// AutofillSurveyManagerTests.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 BrowserServicesKit +@testable import DuckDuckGo + +final class AutofillSurveyManagerTests: XCTestCase { + + private var manager: AutofillSurveyManager! + + override func setUpWithError() throws { + try super.setUpWithError() + + setupUserDefault(with: #file) + manager = AutofillSurveyManager() + manager.resetSurveys() + } + + override func tearDownWithError() throws { + manager.resetSurveys() + manager = nil + + try super.tearDownWithError() + } + + func testSurveyToPresentReturnsCorrectSurvey() { + let settings: [String: Any] = [ + "surveys": [ + ["id": "1", "url": "https://example.com/survey1"], + ["id": "2", "url": "https://example.com/survey2"] + ] + ] + + let survey = manager.surveyToPresent(settings: settings as PrivacyConfigurationData.PrivacyFeature.FeatureSettings) + XCTAssertNotNil(survey) + XCTAssertEqual(survey?.id, "1") + XCTAssertEqual(survey?.url, "https://example.com/survey1") + } + + func testSurveyToPresentSkipsCompletedSurveys() { + let settings: [String: Any] = [ + "surveys": [ + ["id": "1", "url": "https://example.com/survey1"], + ["id": "2", "url": "https://example.com/survey2"] + ] + ] + + manager.markSurveyAsCompleted(id: "1") + + let survey = manager.surveyToPresent(settings: settings as PrivacyConfigurationData.PrivacyFeature.FeatureSettings) + XCTAssertNotNil(survey) + XCTAssertEqual(survey?.id, "2") + XCTAssertEqual(survey?.url, "https://example.com/survey2") + } + + func testBuildSurveyUrlValid() { + let url = "https://example.com/survey" + let accountsCount = 5 + let resultUrl = manager.buildSurveyUrl(url, accountsCount: accountsCount) + XCTAssertNotNil(resultUrl) + XCTAssertEqual(resultUrl?.host, "example.com") + XCTAssertTrue(resultUrl?.query?.contains("saved_passwords=some") ?? false) + } + + func testAddPasswordsCountSurveyParameter() { + let baseURL = URL(string: "https://example.com/survey")! + let modifiedURL = manager.buildSurveyUrl(baseURL.absoluteString, accountsCount: 10) + XCTAssertNotNil(modifiedURL) + XCTAssertEqual(modifiedURL?.query?.contains("saved_passwords=some"), true) + } + + func testPasswordsCountHasCorrectBucketNameSurveyParameter() { + let baseURL = URL(string: "https://example.com/survey")! + var modifiedURL = manager.buildSurveyUrl(baseURL.absoluteString, accountsCount: 0) + XCTAssertEqual(modifiedURL?.query?.contains("saved_passwords=none"), true) + + modifiedURL = manager.buildSurveyUrl(baseURL.absoluteString, accountsCount: 1) + XCTAssertEqual(modifiedURL?.query?.contains("saved_passwords=few"), true) + + modifiedURL = manager.buildSurveyUrl(baseURL.absoluteString, accountsCount: 3) + XCTAssertEqual(modifiedURL?.query?.contains("saved_passwords=few"), true) + + modifiedURL = manager.buildSurveyUrl(baseURL.absoluteString, accountsCount: 4) + XCTAssertEqual(modifiedURL?.query?.contains("saved_passwords=some"), true) + + modifiedURL = manager.buildSurveyUrl(baseURL.absoluteString, accountsCount: 10) + XCTAssertEqual(modifiedURL?.query?.contains("saved_passwords=some"), true) + + modifiedURL = manager.buildSurveyUrl(baseURL.absoluteString, accountsCount: 11) + XCTAssertEqual(modifiedURL?.query?.contains("saved_passwords=many"), true) + + modifiedURL = manager.buildSurveyUrl(baseURL.absoluteString, accountsCount: 49) + XCTAssertEqual(modifiedURL?.query?.contains("saved_passwords=many"), true) + + modifiedURL = manager.buildSurveyUrl(baseURL.absoluteString, accountsCount: 50) + XCTAssertEqual(modifiedURL?.query?.contains("saved_passwords=lots"), true) + + modifiedURL = manager.buildSurveyUrl(baseURL.absoluteString, accountsCount: 100) + XCTAssertEqual(modifiedURL?.query?.contains("saved_passwords=lots"), true) + } +}