From 46bd8e27488cfa28eb9257c3684b6fc06df16c44 Mon Sep 17 00:00:00 2001 From: Lucas Adamski Date: Fri, 30 Aug 2024 15:53:13 -0700 Subject: [PATCH 1/2] remove references to obsolete FBConfig (#3297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1171671347221384/1208194339440141/f Tech Design URL: CC: **Description**: Remove all references to the obsolete FBConfig file, which has been replaced by the shared privacy config. **Steps to test this PR**: 1. 2. **Definition of Done (Internal Only)**: * [ ] Does this PR satisfy our [Definition of Done](https://app.asana.com/0/1202500774821704/1207634633537039/f)? **Copy Testing**: * [ ] Use of correct apostrophes in new copy, ie `’` rather than `'` **Orientation Testing**: * [ ] Portrait * [ ] Landscape **Device Testing**: * [ ] iPhone SE (1st Gen) * [ ] iPhone 8 * [ ] iPhone X * [ ] iPhone 14 Pro * [ ] iPad **OS Testing**: * [ ] iOS 15 * [ ] iOS 16 * [ ] iOS 17 **Theme Testing**: * [ ] Light theme * [ ] Dark theme --- ###### Internal references: [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) --- Core/AppConfigurationURLProvider.swift | 1 - Core/Configuration.swift | 1 - DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- DuckDuckGo/ConfigurationURLDebugViewController.swift | 2 -- 5 files changed, 3 insertions(+), 7 deletions(-) diff --git a/Core/AppConfigurationURLProvider.swift b/Core/AppConfigurationURLProvider.swift index 262b066655..0db485bffc 100644 --- a/Core/AppConfigurationURLProvider.swift +++ b/Core/AppConfigurationURLProvider.swift @@ -31,7 +31,6 @@ struct AppConfigurationURLProvider: ConfigurationURLProviding { case .privacyConfiguration: return URL.privacyConfig case .trackerDataSet: return URL.trackerDataSet case .surrogates: return URL.surrogates - case .FBConfig: fatalError("This feature is not supported on iOS") case .remoteMessagingConfig: return RemoteMessagingClient.Constants.endpoint } } diff --git a/Core/Configuration.swift b/Core/Configuration.swift index 513389febf..3173a08866 100644 --- a/Core/Configuration.swift +++ b/Core/Configuration.swift @@ -30,7 +30,6 @@ public extension Configuration { case .privacyConfiguration: return "privacyConfiguration" case .surrogates: return "surrogates" case .trackerDataSet: return "trackerDataSet" - case .FBConfig: return "FBConfig" case .remoteMessagingConfig: return "remoteMessagingConfig" } } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5cc9ca97eb..5e844b220d 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10650,7 +10650,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 190.0.0; + version = 190.0.1; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 598a4cfc0a..3332763c50 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "ac53011582abcca4aefd66f15308332273eecb49", - "version" : "190.0.0" + "revision" : "245750c9ca559813307641e819fb27c6d294339f", + "version" : "190.0.1" } }, { diff --git a/DuckDuckGo/ConfigurationURLDebugViewController.swift b/DuckDuckGo/ConfigurationURLDebugViewController.swift index bd217e3940..b1e62c79b5 100644 --- a/DuckDuckGo/ConfigurationURLDebugViewController.swift +++ b/DuckDuckGo/ConfigurationURLDebugViewController.swift @@ -179,7 +179,6 @@ struct CustomConfigurationURLProvider: ConfigurationURLProviding { var customPrivacyConfigurationURL: URL? var customTrackerDataSetURL: URL? var customSurrogatesURL: URL? - var customFBConfigURL: URL? var customRemoteMessagingConfigURL: URL? let defaultProvider = AppConfigurationURLProvider() @@ -194,7 +193,6 @@ struct CustomConfigurationURLProvider: ConfigurationURLProviding { case .privacyConfiguration: customURL = customPrivacyConfigurationURL case .trackerDataSet: customURL = customTrackerDataSetURL case .surrogates: customURL = customSurrogatesURL - case .FBConfig: customURL = nil case .remoteMessagingConfig: customURL = customRemoteMessagingConfigURL } return customURL ?? defaultURL From 496f43b1133b429f031a19758e4a9164f1cbd5b0 Mon Sep 17 00:00:00 2001 From: amddg44 Date: Sun, 1 Sep 2024 17:57:07 +0200 Subject: [PATCH 2/2] Sync promotions added to Bookmarks & Passwords screens (#3260) Task/Issue URL: https://app.asana.com/0/72649045549333/1208017419957473/f Tech Design URL: CC: Description: New promo banners added + Sync screen updates to include link to download app on other devices --- Core/FeatureFlag.swift | 6 + Core/PixelEvent.swift | 18 +- Core/UserDefaultsPropertyWrapper.swift | 3 + DuckDuckGo.xcodeproj/project.pbxproj | 30 ++- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Sync-Start-96.imageset/Contents.json | 12 + .../Sync-Start-96.imageset/Sync-Start-96.svg | 10 + DuckDuckGo/AutofillLoginListViewModel.swift | 22 +- ...ofillLoginSettingsListViewController.swift | 70 +++++- DuckDuckGo/Base.lproj/Bookmarks.storyboard | 19 +- DuckDuckGo/BookmarksViewController.swift | 140 +++++++++++- DuckDuckGo/Debug.storyboard | 25 ++- DuckDuckGo/MainViewController+Segues.swift | 8 +- DuckDuckGo/RootDebugViewController.swift | 6 + DuckDuckGo/SettingsLegacyViewProvider.swift | 5 +- DuckDuckGo/SettingsState.swift | 4 +- DuckDuckGo/SettingsViewModel.swift | 10 +- .../Share-Apple-24.imageset/Contents.json | 15 ++ .../Share-Apple-24.svg | 4 + .../App-Download-128.svg | 16 ++ .../Contents.json | 12 + .../Sync-Downloads-24.imageset/Contents.json | 12 + .../Downloads-24.svg | 4 + DuckDuckGo/SyncPromoManager.swift | 94 ++++++++ DuckDuckGo/SyncPromoView.swift | 96 ++++++++ DuckDuckGo/SyncPromoViewModel.swift | 68 ++++++ ...SettingsViewController+PlatformLinks.swift | 57 +++++ ...cSettingsViewController+SyncDelegate.swift | 31 ++- DuckDuckGo/SyncSettingsViewController.swift | 9 +- DuckDuckGo/UserAuthenticator.swift | 13 -- DuckDuckGo/UserText.swift | 11 +- DuckDuckGo/en.lproj/Localizable.strings | 21 ++ .../AutofillLoginListViewModelTests.swift | 60 ++--- DuckDuckGoTests/SyncPromoManagerTests.swift | 179 +++++++++++++++ .../SyncUI/SyncManagementViewModelTests.swift | 15 +- .../Resources/en.lproj/Localizable.strings | 24 ++ .../ViewModels/SyncSettingsViewModel.swift | 27 +++ .../SyncUI/Views/DeviceConnectedView.swift | 26 ++- .../SyncUI/Views/Internal/UserText.swift | 12 + .../SyncUI/Views/PlatformLinksView.swift | 206 ++++++++++++++++++ .../SyncUI/Views/SyncSettingsView.swift | 4 + .../Views/SyncSettingsViewExtension.swift | 15 ++ 42 files changed, 1322 insertions(+), 101 deletions(-) create mode 100644 DuckDuckGo/Assets.xcassets/Sync-Start-96.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Sync-Start-96.imageset/Sync-Start-96.svg create mode 100644 DuckDuckGo/SyncAssets.xcassets/Share-Apple-24.imageset/Contents.json create mode 100644 DuckDuckGo/SyncAssets.xcassets/Share-Apple-24.imageset/Share-Apple-24.svg create mode 100644 DuckDuckGo/SyncAssets.xcassets/Sync-App-Download-128.imageset/App-Download-128.svg create mode 100644 DuckDuckGo/SyncAssets.xcassets/Sync-App-Download-128.imageset/Contents.json create mode 100644 DuckDuckGo/SyncAssets.xcassets/Sync-Downloads-24.imageset/Contents.json create mode 100644 DuckDuckGo/SyncAssets.xcassets/Sync-Downloads-24.imageset/Downloads-24.svg create mode 100644 DuckDuckGo/SyncPromoManager.swift create mode 100644 DuckDuckGo/SyncPromoView.swift create mode 100644 DuckDuckGo/SyncPromoViewModel.swift create mode 100644 DuckDuckGo/SyncSettingsViewController+PlatformLinks.swift create mode 100644 DuckDuckGoTests/SyncPromoManagerTests.swift create mode 100644 LocalPackages/SyncUI/Sources/SyncUI/Views/PlatformLinksView.swift diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index 453f602486..bab8292178 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -37,6 +37,8 @@ public enum FeatureFlag: String { case newTabPageSections case duckPlayer case sslCertificatesBypass + case syncPromotionBookmarks + case syncPromotionPasswords } extension FeatureFlag: FeatureFlagSourceProviding { @@ -74,6 +76,10 @@ extension FeatureFlag: FeatureFlagSourceProviding { return .remoteReleasable(.feature(.duckPlayer)) case .sslCertificatesBypass: return .remoteReleasable(.subfeature(sslCertificatesSubfeature.allowBypass)) + case .syncPromotionBookmarks: + return .remoteReleasable(.subfeature(SyncPromotionSubfeature.bookmarks)) + case .syncPromotionPasswords: + return .remoteReleasable(.subfeature(SyncPromotionSubfeature.passwords)) } } } diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 0acf868275..5bf18b4751 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -292,7 +292,6 @@ extension Pixel { case autofillManagementSaveLogin case autofillManagementUpdateLogin - case autofillMultipleAuthCallsTriggered case autofillLoginsReportFailure case autofillLoginsReportAvailable case autofillLoginsReportConfirmationPromptDisplayed @@ -611,6 +610,14 @@ extension Pixel { case syncDeleteAccountError case syncLoginExistingAccountError + case syncGetOtherDevices + case syncGetOtherDevicesCopy + case syncGetOtherDevicesShare + + case syncPromoDisplayed + case syncPromoConfirmed + case syncPromoDismissed + case swipeTabsUsedDaily case swipeToOpenNewTab @@ -1085,7 +1092,6 @@ extension Pixel.Event { case .autofillManagementUpdateLogin: return "m_autofill_management_update_login" - case .autofillMultipleAuthCallsTriggered: return "m_autofill_multiple_auth_calls_triggered" case .autofillLoginsReportFailure: return "autofill_logins_report_failure" case .autofillLoginsReportAvailable: return "autofill_logins_report_available" case .autofillLoginsReportConfirmationPromptDisplayed: return "autofill_logins_report_confirmation_prompt_displayed" @@ -1380,6 +1386,14 @@ extension Pixel.Event { case .syncDeleteAccountError: return "m_d_sync_delete_account_error" case .syncLoginExistingAccountError: return "m_d_sync_login_existing_account_error" + case .syncGetOtherDevices: return "sync_get_other_devices" + case .syncGetOtherDevicesCopy: return "sync_get_other_devices_copy" + case .syncGetOtherDevicesShare: return "sync_get_other_devices_share" + + case .syncPromoDisplayed: return "sync_promotion_displayed" + case .syncPromoConfirmed: return "sync_promotion_confirmed" + case .syncPromoDismissed: return "sync_promotion_dismissed" + case .swipeTabsUsedDaily: return "m_swipe-tabs-used-daily" case .swipeToOpenNewTab: return "m_addressbar_swipe_new_tab" diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 5f4b8f1053..5c3d632f45 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -93,6 +93,9 @@ public struct UserDefaultsWrapper { case autofillFillDate = "com.duckduckgo.app.autofill.FillDate" case autofillOnboardedUser = "com.duckduckgo.app.autofill.OnboardedUser" + case syncPromoBookmarksDismissed = "com.duckduckgo.app.sync.PromoBookmarksDismissed" + case syncPromoPasswordsDismissed = "com.duckduckgo.app.sync.PromoPasswordsDismissed" + // .v2 suffix added to fix https://app.asana.com/0/547792610048271/1206524375402369/f case featureFlaggingDidVerifyInternalUser = "com.duckduckgo.app.featureFlaggingDidVerifyInternalUser.v2" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5e844b220d..d563f45b8e 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -853,14 +853,19 @@ C1B924B72ACD6E6800EE7B06 /* AutofillNeverSavedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B924B62ACD6E6800EE7B06 /* AutofillNeverSavedTableViewCell.swift */; }; C1BF0BA529B63D7200482B73 /* AutofillLoginPromptHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BF0BA429B63D7200482B73 /* AutofillLoginPromptHelper.swift */; }; C1BF0BA929B63E2200482B73 /* AutofillLoginPromptViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BF0BA729B63E1A00482B73 /* AutofillLoginPromptViewModelTests.swift */; }; + C1BF26152C74D10F00F6405E /* SyncPromoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BF26142C74D10F00F6405E /* SyncPromoManager.swift */; }; C1CCCBA7283E101500CF3791 /* FaviconsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CCCBA6283E101500CF3791 /* FaviconsHelper.swift */; }; C1CDA3162AFB9C7F006D1476 /* AutofillNeverPromptWebsitesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CDA3152AFB9C7F006D1476 /* AutofillNeverPromptWebsitesManager.swift */; }; C1CDA31E2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CDA31D2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift */; }; C1D21E2D293A5965006E5A05 /* AutofillLoginSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D21E2C293A5965006E5A05 /* AutofillLoginSession.swift */; }; C1D21E2F293A599C006E5A05 /* AutofillLoginSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D21E2E293A599C006E5A05 /* AutofillLoginSessionTests.swift */; }; + C1EA86602C74CB6C00E8604D /* SyncPromoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EA865F2C74CB6C00E8604D /* SyncPromoView.swift */; }; + C1EA86622C74CB8B00E8604D /* SyncPromoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EA86612C74CB8B00E8604D /* SyncPromoViewModel.swift */; }; C1F341C52A6924000032057B /* EmailAddressPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F341C42A6924000032057B /* EmailAddressPromptView.swift */; }; C1F341C72A6924100032057B /* EmailAddressPromptViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F341C62A6924100032057B /* EmailAddressPromptViewModel.swift */; }; C1F341C92A6926920032057B /* EmailAddressPromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F341C82A6926920032057B /* EmailAddressPromptViewController.swift */; }; + C1FFBD462C761BE20073622B /* SyncPromoManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FFBD452C761BE20073622B /* SyncPromoManagerTests.swift */; }; + C1FFBD482C7749A90073622B /* SyncSettingsViewController+PlatformLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FFBD472C7749A90073622B /* SyncSettingsViewController+PlatformLinks.swift */; }; CB1143DE2AF6D4B600C1CCD3 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = CB1143DC2AF6D4B600C1CCD3 /* InfoPlist.strings */; }; CB258D1229A4F24900DEBA24 /* ConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB258D0F29A4D0FD00DEBA24 /* ConfigurationManager.swift */; }; CB258D1329A4F24E00DEBA24 /* ConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB84C7C029A3F0280088A5B8 /* ConfigurationStore.swift */; }; @@ -2612,14 +2617,19 @@ C1B924B62ACD6E6800EE7B06 /* AutofillNeverSavedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillNeverSavedTableViewCell.swift; sourceTree = ""; }; C1BF0BA429B63D7200482B73 /* AutofillLoginPromptHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillLoginPromptHelper.swift; sourceTree = ""; }; C1BF0BA729B63E1A00482B73 /* AutofillLoginPromptViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillLoginPromptViewModelTests.swift; sourceTree = ""; }; + C1BF26142C74D10F00F6405E /* SyncPromoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPromoManager.swift; sourceTree = ""; }; C1CCCBA6283E101500CF3791 /* FaviconsHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FaviconsHelper.swift; sourceTree = ""; }; C1CDA3152AFB9C7F006D1476 /* AutofillNeverPromptWebsitesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillNeverPromptWebsitesManager.swift; sourceTree = ""; }; C1CDA31D2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillNeverPromptWebsitesManagerTests.swift; sourceTree = ""; }; C1D21E2C293A5965006E5A05 /* AutofillLoginSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginSession.swift; sourceTree = ""; }; C1D21E2E293A599C006E5A05 /* AutofillLoginSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginSessionTests.swift; sourceTree = ""; }; + C1EA865F2C74CB6C00E8604D /* SyncPromoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPromoView.swift; sourceTree = ""; }; + C1EA86612C74CB8B00E8604D /* SyncPromoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPromoViewModel.swift; sourceTree = ""; }; C1F341C42A6924000032057B /* EmailAddressPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailAddressPromptView.swift; sourceTree = ""; }; C1F341C62A6924100032057B /* EmailAddressPromptViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailAddressPromptViewModel.swift; sourceTree = ""; }; C1F341C82A6926920032057B /* EmailAddressPromptViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailAddressPromptViewController.swift; sourceTree = ""; }; + C1FFBD452C761BE20073622B /* SyncPromoManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPromoManagerTests.swift; sourceTree = ""; }; + C1FFBD472C7749A90073622B /* SyncSettingsViewController+PlatformLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncSettingsViewController+PlatformLinks.swift"; sourceTree = ""; }; CB1143DD2AF6D4B600C1CCD3 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/InfoPlist.strings; sourceTree = ""; }; CB15F4762AF6D5100062A994 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; CB18F2712AF6D4E400A0F8FE /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -3671,6 +3681,7 @@ 5694372F2BE3F63B00C0881B /* SyncErrorHandlerSyncPausedAlertsTests.swift */, 569437322BE4E3DD00C0881B /* SyncErrorHandlerSyncErrorsAlertsTests.swift */, 569437352BE5160600C0881B /* SyncSettingsViewControllerErrorTests.swift */, + C1FFBD452C761BE20073622B /* SyncPromoManagerTests.swift */, ); name = Sync; sourceTree = ""; @@ -4437,6 +4448,7 @@ 85582DFF29D7409700E9AE35 /* SyncSettingsViewController+PDFRendering.swift */, 85F98F91296F32BD00742F4A /* SyncSettingsViewController.swift */, 85047C762A0D5D3D00D2FF3F /* SyncSettingsViewController+SyncDelegate.swift */, + C1FFBD472C7749A90073622B /* SyncSettingsViewController+PlatformLinks.swift */, ); name = Controllers; sourceTree = ""; @@ -4490,6 +4502,7 @@ 85F0E97229952D7A003D5181 /* DuckDuckGo Recovery Document.pdf */, 85DD44232976C7A8005CC388 /* Controllers */, 560E990E2BEE2CB800507CE0 /* SyncErrorMessage.swift */, + C1EA865E2C74CB5500E8604D /* Promotion */, ); name = Sync; sourceTree = ""; @@ -4986,6 +4999,16 @@ name = EmailSignup; sourceTree = ""; }; + C1EA865E2C74CB5500E8604D /* Promotion */ = { + isa = PBXGroup; + children = ( + C1EA865F2C74CB6C00E8604D /* SyncPromoView.swift */, + C1EA86612C74CB8B00E8604D /* SyncPromoViewModel.swift */, + C1BF26142C74D10F00F6405E /* SyncPromoManager.swift */, + ); + name = Promotion; + sourceTree = ""; + }; C1F341C32A6923D70032057B /* EmailAddressPrompt */ = { isa = PBXGroup; children = ( @@ -7116,6 +7139,7 @@ D664C7CE2B289AA200CBFA76 /* SubscriptionPagesUseSubscriptionFeature.swift in Sources */, EE9D68DC2AE16AE100B55EF4 /* NotificationsAuthorizationController.swift in Sources */, AA3D854923DA1DFB00788410 /* AppIcon.swift in Sources */, + C1BF26152C74D10F00F6405E /* SyncPromoManager.swift in Sources */, D6E83C2E2B1EA06E006C8AFB /* SettingsViewModel.swift in Sources */, 6FD8E5222C5BA5C400345670 /* NewTabPageIntroMessageSetup.swift in Sources */, 8590CB612684D0600089F6BF /* CookieDebugViewController.swift in Sources */, @@ -7523,6 +7547,7 @@ EE4BE0092A740BED00CD6AA8 /* ClearTextField.swift in Sources */, 56D060282C380D83003BAEB5 /* OnboardingSuggestedSearchesProvider.swift in Sources */, F44D279C27F331BB0037F371 /* AutofillLoginPromptView.swift in Sources */, + C1EA86622C74CB8B00E8604D /* SyncPromoViewModel.swift in Sources */, F1FDC9302BF4E0B3006B1435 /* SubscriptionEnvironment+Default.swift in Sources */, CBD4F13E279EBFAB00B20FD7 /* HomeMessageView.swift in Sources */, 851DFD87212C39D300D95F20 /* TabSwitcherButton.swift in Sources */, @@ -7550,6 +7575,7 @@ D6E83C682B23B6A3006C8AFB /* FontSettings.swift in Sources */, 1DEAADF62BA4809400E25A97 /* CookiePopUpProtectionView.swift in Sources */, 31EF52E1281B3BDC0034796E /* AutofillLoginListItemViewModel.swift in Sources */, + C1EA86602C74CB6C00E8604D /* SyncPromoView.swift in Sources */, 1E4FAA6627D8DFC800ADC5B3 /* CompleteDownloadRowViewModel.swift in Sources */, 3712091E2C21E390003ADF3D /* RemoteMessagingStoreErrorHandling.swift in Sources */, 83004E862193E5ED00DA013C /* TabViewControllerBrowsingMenuExtension.swift in Sources */, @@ -7570,6 +7596,7 @@ F16390821E648B7A005B4550 /* HomeViewController.swift in Sources */, 985892522260B1B200EEB31B /* ProgressView.swift in Sources */, 85BA585A1F3506AE00C6E8CA /* AppSettings.swift in Sources */, + C1FFBD482C7749A90073622B /* SyncSettingsViewController+PlatformLinks.swift in Sources */, 3151F0EA27357FBA00226F58 /* SpeechRecognizer.swift in Sources */, 85B9814E2B5EB618009AC9A6 /* SwipeTabsCoordinator.swift in Sources */, 985AAE4524899369007A43EC /* HomeScreenTransition.swift in Sources */, @@ -7785,6 +7812,7 @@ F17D72391E8B35C6003E8B0E /* AppURLsTests.swift in Sources */, F1134ED61F40F29F00B73467 /* StatisticsUserDefaultsTests.swift in Sources */, 98629D342C21BE37001E6031 /* BookmarksStateValidationTests.swift in Sources */, + C1FFBD462C761BE20073622B /* SyncPromoManagerTests.swift in Sources */, F1BDDBFF2C340D9C00459306 /* SubscriptionPagesUseSubscriptionFeatureTests.swift in Sources */, 98EA2C3C218B9AAD0023E1DC /* ThemeManagerTests.swift in Sources */, 569437292BDD487600C0881B /* SyncCredentialsAdapterTests.swift in Sources */, @@ -10650,7 +10678,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 190.0.1; + version = 190.0.2; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3332763c50..63dfe15758 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "245750c9ca559813307641e819fb27c6d294339f", - "version" : "190.0.1" + "revision" : "2e9282d79f4a36ad851e9e130ffd936a5c8e74c7", + "version" : "190.0.2" } }, { diff --git a/DuckDuckGo/Assets.xcassets/Sync-Start-96.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Sync-Start-96.imageset/Contents.json new file mode 100644 index 0000000000..9a2716eb82 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Sync-Start-96.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Sync-Start-96.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Sync-Start-96.imageset/Sync-Start-96.svg b/DuckDuckGo/Assets.xcassets/Sync-Start-96.imageset/Sync-Start-96.svg new file mode 100644 index 0000000000..c607af83cd --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Sync-Start-96.imageset/Sync-Start-96.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/DuckDuckGo/AutofillLoginListViewModel.swift b/DuckDuckGo/AutofillLoginListViewModel.swift index d4d507ee89..77ac3f4bb1 100644 --- a/DuckDuckGo/AutofillLoginListViewModel.swift +++ b/DuckDuckGo/AutofillLoginListViewModel.swift @@ -23,6 +23,7 @@ import Common import UIKit import Combine import Core +import DDGSync import PrivacyDashboard import os.log @@ -93,6 +94,7 @@ final class AutofillLoginListViewModel: ObservableObject { private var cachedDeletedCredentials: SecureVaultModels.WebsiteCredentials? private let autofillDomainNameUrlMatcher = AutofillDomainNameUrlMatcher() private let autofillDomainNameUrlSort = AutofillDomainNameUrlSort() + private let syncService: DDGSyncing private var showBreakageReporter: Bool = false private lazy var reporterDateFormatter = { @@ -106,6 +108,8 @@ final class AutofillLoginListViewModel: ObservableObject { return settings["monitorIntervalDays"] as? Int ?? 42 }() + private lazy var syncPromoManager: SyncPromoManaging = SyncPromoManager(syncService: syncService) + 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]) @@ -151,7 +155,8 @@ final class AutofillLoginListViewModel: ObservableObject { currentTabUid: String? = nil, autofillNeverPromptWebsitesManager: AutofillNeverPromptWebsitesManager = AppDependencyProvider.shared.autofillNeverPromptWebsitesManager, privacyConfig: PrivacyConfiguration = ContentBlocking.shared.privacyConfigurationManager.privacyConfig, - keyValueStore: KeyValueStoringDictionaryRepresentable = UserDefaults.standard) { + keyValueStore: KeyValueStoringDictionaryRepresentable = UserDefaults.standard, + syncService: DDGSyncing) { self.appSettings = appSettings self.tld = tld self.secureVault = secureVault @@ -160,6 +165,7 @@ final class AutofillLoginListViewModel: ObservableObject { self.autofillNeverPromptWebsitesManager = autofillNeverPromptWebsitesManager self.privacyConfig = privacyConfig self.keyValueStore = keyValueStore + self.syncService = syncService if let count = getAccountsCount() { authenticationNotRequired = count == 0 || AppDependencyProvider.shared.autofillLoginSession.isSessionValid @@ -313,6 +319,18 @@ final class AutofillLoginListViewModel: ObservableObject { return alert } + func shouldShowSyncPromo() -> Bool { + return viewState == .showItems + && !isEditing + && syncPromoManager.shouldPresentPromoFor(.passwords, count: accountsCount) + } + + func dismissSyncPromo() { + syncPromoManager.dismissPromoFor(.passwords) + } + + // MARK: Private Methods + private func saveReport(for currentTabUrl: URL) { let report = BrokenSiteReport(siteUrl: currentTabUrl, category: "", @@ -342,8 +360,6 @@ final class AutofillLoginListViewModel: ObservableObject { try? breakageReporter.report(report, reportMode: .regular, daysToExpiry: breakageReportIntervalDays) } - // MARK: Private Methods - private func getAccountsCount() -> Int? { guard let secureVault = secureVault else { return nil diff --git a/DuckDuckGo/AutofillLoginSettingsListViewController.swift b/DuckDuckGo/AutofillLoginSettingsListViewController.swift index 2ea37e102d..b3823fd806 100644 --- a/DuckDuckGo/AutofillLoginSettingsListViewController.swift +++ b/DuckDuckGo/AutofillLoginSettingsListViewController.swift @@ -149,6 +149,22 @@ 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 lockedViewBottomConstraint: NSLayoutConstraint = { NSLayoutConstraint(item: tableView, attribute: .bottom, @@ -185,7 +201,7 @@ final class AutofillLoginSettingsListViewController: UIViewController { if secureVault == nil { Logger.autofill.fault("Failed to make vault") } - self.viewModel = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: secureVault, currentTabUrl: currentTabUrl, currentTabUid: currentTabUid) + self.viewModel = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: secureVault, currentTabUrl: currentTabUrl, currentTabUid: currentTabUid, syncService: syncService) self.syncService = syncService self.selectedAccount = selectedAccount self.openSearch = openSearch @@ -266,6 +282,7 @@ final class AutofillLoginSettingsListViewController: UIViewController { updateNavigationBarButtons() updateSearchController() updateToolbar() + updateTableHeaderView() } @objc @@ -332,6 +349,7 @@ final class AutofillLoginSettingsListViewController: UIViewController { .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateToolbarLabel() + self?.updateTableHeaderView() } .store(in: &cancellables) @@ -404,6 +422,21 @@ final class AutofillLoginSettingsListViewController: UIViewController { navigationController?.pushViewController(importController, animated: true) } + private func segueToSync(source: String? = nil) { + if let settingsVC = self.navigationController?.children.first as? SettingsHostingController { + navigationController?.popToRootViewController(animated: true) + if let source = source { + settingsVC.viewModel.shouldPresentSyncViewWithSource(source) + } else { + settingsVC.viewModel.presentLegacyView(.sync) + } + } else if let mainVC = self.presentingViewController as? MainViewController { + dismiss(animated: true) { + mainVC.segueToSettingsSync(with: source) + } + } + } + private func showSelectedAccountIfRequired() { if let account = selectedAccount { showAccountDetails(account) @@ -467,7 +500,9 @@ final class AutofillLoginSettingsListViewController: UIViewController { if authenticated { let accountsCount = self?.viewModel.accountsCount ?? 0 self?.viewModel.clearAllAccounts() - self?.presentDeleteAllConfirmation(accountsCount) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { + self?.presentDeleteAllConfirmation(accountsCount) + }) } } ) @@ -563,6 +598,7 @@ final class AutofillLoginSettingsListViewController: UIViewController { updateNavigationBarButtons() updateSearchController() updateToolbar() + updateTableHeaderView() tableView.reloadData() } @@ -635,6 +671,27 @@ final class AutofillLoginSettingsListViewController: UIViewController { accountsCountLabel.sizeToFit() } + private func updateTableHeaderView() { + if viewModel.shouldShowSyncPromo() { + guard tableView.frame != .zero, tableView.tableHeaderView != syncPromoViewHostingController.view else { + return + } + + addChild(syncPromoViewHostingController) + + 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 + + syncPromoViewHostingController.didMove(toParent: self) + } else { + guard tableView.tableHeaderView != nil else { + return + } + tableView.tableHeaderView = nil + } + } + private func installSubviews() { view.addSubview(tableView) tableView.addSubview(emptySearchView) @@ -952,14 +1009,7 @@ extension AutofillLoginSettingsListViewController: AutofillLoginDetailsViewContr extension AutofillLoginSettingsListViewController: ImportPasswordsViewControllerDelegate { func importPasswordsViewControllerDidRequestOpenSync(_ viewController: ImportPasswordsViewController) { - if let settingsVC = self.navigationController?.children.first as? SettingsHostingController { - navigationController?.popToRootViewController(animated: true) - settingsVC.viewModel.presentLegacyView(.sync) - } else if let mainVC = self.presentingViewController as? MainViewController { - dismiss(animated: true) { - mainVC.segueToSettingsSync() - } - } + segueToSync() } } diff --git a/DuckDuckGo/Base.lproj/Bookmarks.storyboard b/DuckDuckGo/Base.lproj/Bookmarks.storyboard index c8dfd07c4d..3b58f932f2 100644 --- a/DuckDuckGo/Base.lproj/Bookmarks.storyboard +++ b/DuckDuckGo/Base.lproj/Bookmarks.storyboard @@ -1,9 +1,9 @@ - + - + @@ -122,17 +122,9 @@ - - - - - - - - - + @@ -196,7 +188,7 @@ - + @@ -230,7 +222,7 @@ - + @@ -351,7 +343,6 @@ - diff --git a/DuckDuckGo/BookmarksViewController.swift b/DuckDuckGo/BookmarksViewController.swift index 6dd701b5bc..109542d87e 100644 --- a/DuckDuckGo/BookmarksViewController.swift +++ b/DuckDuckGo/BookmarksViewController.swift @@ -29,6 +29,7 @@ import Combine import Persistence import WidgetKit import os.log +import SwiftUI class BookmarksViewController: UIViewController, UITableViewDelegate { @@ -48,7 +49,6 @@ class BookmarksViewController: UIViewController, UITableViewDelegate { // Need to retain these as we're going to add/remove them from the view hierarchy @IBOutlet var doneButton: UIBarButtonItem! @IBOutlet var emptyStateContainer: UIView! - @IBOutlet var searchBar: UISearchBar! private let bookmarksDatabase: CoreDataDatabase private let favicons: Favicons @@ -87,7 +87,58 @@ class BookmarksViewController: UIViewController, UITableViewDelegate { children: [exportAction(), importAction()]) } + private lazy var headerView: UIView = UIView() + + private lazy var searchBar: UISearchBar = { + let searchBar = UISearchBar() + searchBar.searchBarStyle = .minimal + searchBar.placeholder = UserText.bookmarkSearchBarPlaceholder + searchBar.delegate = self + searchBar.sizeToFit() + return searchBar + }() + private var searchController: UISearchController? + + private var isSearching: Bool = false { + didSet { + guard isSearching != oldValue else { + return + } + refreshTableHeaderView() + } + } + + private lazy var searchBarBottomConstraint: NSLayoutConstraint = { + searchBar.bottomAnchor.constraint(equalTo: headerView.bottomAnchor) + }() + + private lazy var syncPromoViewTopConstraint: NSLayoutConstraint = { + syncPromoViewHostingController.view.topAnchor.constraint(equalTo: searchBar.bottomAnchor) + }() + + private lazy var syncPromoManager: SyncPromoManaging = SyncPromoManager(syncService: syncService) + + private lazy var syncPromoViewHostingController: UIHostingController = { + let headerView = SyncPromoView(viewModel: SyncPromoViewModel(touchpointType: .bookmarks, primaryButtonAction: { [weak self] in + if let mainVC = self?.presentingViewController as? MainViewController { + self?.dismiss(animated: true) { + mainVC.segueToSettingsSync(with: "promotion_bookmarks") + } + } + Pixel.fire(.syncPromoConfirmed, withAdditionalParameters: ["source": SyncPromoManager.Touchpoint.bookmarks.rawValue]) + }, dismissButtonAction: { [weak self] in + self?.syncPromoManager.dismissPromoFor(.bookmarks) + self?.refreshTableHeaderView() + })) + + Pixel.fire(.syncPromoDisplayed, withAdditionalParameters: ["source": SyncPromoManager.Touchpoint.bookmarks.rawValue]) + + let hostingController = UIHostingController(rootView: headerView) + hostingController.view.backgroundColor = .clear + return hostingController + }() + weak var delegate: BookmarksDelegate? fileprivate var viewModelCancellable: AnyCancellable? @@ -799,21 +850,101 @@ class BookmarksViewController: UIViewController, UITableViewDelegate { emptyStateContainer.isHidden = false importFooterButton.isHidden = false importFooterButton.isEnabled = true - searchBar.removeFromSuperview() + tableView.tableHeaderView = nil } private func hideEmptyState() { emptyStateContainer.isHidden = true importFooterButton.isHidden = true importFooterButton.isEnabled = false - tableView.addSubview(searchBar) - tableView.tableHeaderView = searchBar + configureSearchBarHeaderView() + refreshTableHeaderView() + } + + private func configureSearchBarHeaderView() { + guard !headerView.subviews.contains(searchBar) else { + return + } + + headerView.addSubview(searchBar) + searchBar.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + searchBar.topAnchor.constraint(equalTo: headerView.topAnchor), + searchBar.leadingAnchor.constraint(equalTo: headerView.leadingAnchor), + searchBar.trailingAnchor.constraint(equalTo: headerView.trailingAnchor), + searchBarBottomConstraint + ]) + + searchBar.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) + + headerView.frame = CGRect(x: 0, y: 0, width: tableView.frame.width, height: searchBar.intrinsicContentSize.height) + + tableView.tableHeaderView = headerView + } + + private func showSyncPromo() -> Bool { + return !tableView.isEditing + && !isSearching + && !isNested + && syncPromoManager.shouldPresentPromoFor(.bookmarks, count: viewModel.totalBookmarksCount) + } + + private func refreshTableHeaderView() { + if showSyncPromo() { + guard !headerView.subviews.contains(syncPromoViewHostingController.view) else { + return + } + + syncPromoViewHostingController.view.translatesAutoresizingMaskIntoConstraints = false + addChild(syncPromoViewHostingController) + headerView.addSubview(syncPromoViewHostingController.view) + + NSLayoutConstraint.deactivate([ + searchBarBottomConstraint + ]) + + NSLayoutConstraint.activate([ + syncPromoViewTopConstraint, + syncPromoViewHostingController.view.leadingAnchor.constraint(equalTo: headerView.leadingAnchor), + syncPromoViewHostingController.view.trailingAnchor.constraint(equalTo: headerView.trailingAnchor), + syncPromoViewHostingController.view.bottomAnchor.constraint(equalTo: headerView.bottomAnchor) + ]) + + syncPromoViewHostingController.view.setNeedsLayout() + syncPromoViewHostingController.view.layoutIfNeeded() + + let horizontalMargins = tableView.layoutMargins.left + tableView.layoutMargins.right + 40 + let syncPromoViewHeight = syncPromoViewHostingController.view.sizeThatFits(CGSize(width: tableView.bounds.width - horizontalMargins, height: CGFloat.greatestFiniteMagnitude)).height + let totalHeight = searchBar.intrinsicContentSize.height + syncPromoViewHeight + headerView.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: totalHeight) + + tableView.tableHeaderView = headerView + syncPromoViewHostingController.didMove(toParent: self) + + tableView.layoutIfNeeded() + } else if !headerView.subviews.contains(searchBar) || headerView.subviews.count != 1 { + + if syncPromoViewHostingController.view != nil { + syncPromoViewHostingController.view?.removeFromSuperview() + } + + syncPromoViewTopConstraint.isActive = false + searchBarBottomConstraint.isActive = true + + headerView.frame = CGRect(x: 0, y: 0, width: tableView.frame.width, height: searchBar.intrinsicContentSize.height) + + tableView.tableHeaderView = headerView + searchBar.layoutIfNeeded() + tableView.layoutIfNeeded() + } } private func prepareForSearching() { finishEditing() disableEditButton() disableAddFolderButton() + isSearching = true } private func finishSearching() { @@ -822,6 +953,7 @@ class BookmarksViewController: UIViewController, UITableViewDelegate { enableEditButton() enableAddFolderButton() + isSearching = false } fileprivate func select(bookmark: BookmarkEntity) { diff --git a/DuckDuckGo/Debug.storyboard b/DuckDuckGo/Debug.storyboard index fcff857415..7f63bea2d3 100644 --- a/DuckDuckGo/Debug.storyboard +++ b/DuckDuckGo/Debug.storyboard @@ -300,7 +300,7 @@ - + @@ -359,6 +359,15 @@ + + + + + + + + + @@ -932,17 +941,17 @@ - + - + - + - + diff --git a/DuckDuckGo/MainViewController+Segues.swift b/DuckDuckGo/MainViewController+Segues.swift index d8855e560a..e055cc21dd 100644 --- a/DuckDuckGo/MainViewController+Segues.swift +++ b/DuckDuckGo/MainViewController+Segues.swift @@ -285,11 +285,15 @@ extension MainViewController { } } - func segueToSettingsSync() { + func segueToSettingsSync(with source: String? = nil) { Logger.lifecycle.debug(#function) hideAllHighlightsIfNeeded() launchSettings { - $0.presentLegacyView(.sync) + if let source = source { + $0.shouldPresentSyncViewWithSource(source) + } else { + $0.presentLegacyView(.sync) + } } } diff --git a/DuckDuckGo/RootDebugViewController.swift b/DuckDuckGo/RootDebugViewController.swift index 66216547db..843237c7d9 100644 --- a/DuckDuckGo/RootDebugViewController.swift +++ b/DuckDuckGo/RootDebugViewController.swift @@ -46,6 +46,7 @@ class RootDebugViewController: UITableViewController { case refreshConfig = 672 case newTabPageSections = 674 case showNewOnboardingIntro = 676 + case resetSyncPromoPrompts = 677 } @IBOutlet weak var shareButton: UIBarButtonItem! @@ -170,6 +171,11 @@ class RootDebugViewController: UITableViewController { show(controller, sender: nil) case .showNewOnboardingIntro: showOnboardingIntro() + case .resetSyncPromoPrompts: + guard let sync = sync else { return } + let syncPromoPresenter = SyncPromoManager(syncService: sync) + syncPromoPresenter.resetPromos() + ActionMessageView.present(message: "Sync Promos reset") } } } diff --git a/DuckDuckGo/SettingsLegacyViewProvider.swift b/DuckDuckGo/SettingsLegacyViewProvider.swift index af40a39ab6..880156e2ee 100644 --- a/DuckDuckGo/SettingsLegacyViewProvider.swift +++ b/DuckDuckGo/SettingsLegacyViewProvider.swift @@ -83,12 +83,13 @@ class SettingsLegacyViewProvider: ObservableObject { var feedback: UIViewController { instantiate("Feedback", fromStoryboard: "Feedback") } @MainActor - var syncSettings: UIViewController { + func syncSettings(source: String? = nil) -> SyncSettingsViewController { return SyncSettingsViewController(syncService: self.syncService, syncBookmarksAdapter: self.syncDataProviders.bookmarksAdapter, syncCredentialsAdapter: self.syncDataProviders.credentialsAdapter, appSettings: self.appSettings, - syncPausedStateManager: self.syncPausedStateManager) + syncPausedStateManager: self.syncPausedStateManager, + source: source) } func loginSettings(delegate: AutofillLoginSettingsListViewControllerDelegate, diff --git a/DuckDuckGo/SettingsState.swift b/DuckDuckGo/SettingsState.swift index 755ae10d13..2548e20354 100644 --- a/DuckDuckGo/SettingsState.swift +++ b/DuckDuckGo/SettingsState.swift @@ -94,7 +94,8 @@ struct SettingsState { // Sync Properties var sync: SyncSettings - + var syncSource: String? + // Duck Player Mode var duckPlayerEnabled: Bool var duckPlayerMode: DuckPlayerMode? @@ -133,6 +134,7 @@ struct SettingsState { platform: .unknown, isShowingStripeView: false), sync: SyncSettings(enabled: false, title: ""), + syncSource: nil, duckPlayerEnabled: false, duckPlayerMode: .alwaysAsk ) diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index 66f6fcf195..7fbce016d5 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -408,6 +408,7 @@ extension SettingsViewModel { networkProtectionConnected: false, subscription: SettingsState.defaults.subscription, sync: getSyncState(), + syncSource: nil, duckPlayerEnabled: featureFlagger.isFeatureOn(.duckPlayer) || shouldDisplayDuckPlayerContingencyMessage, duckPlayerMode: appSettings.duckPlayerMode ) @@ -516,7 +517,12 @@ extension SettingsViewModel { state.activeWebsiteAccount = accountDetails presentLegacyView(.logins) } - + + @MainActor func shouldPresentSyncViewWithSource(_ source: String? = nil) { + state.syncSource = source + presentLegacyView(.sync) + } + func openEmailProtection() { UIApplication.shared.open(URL.emailProtectionQuickLink, options: [:], @@ -582,7 +588,7 @@ extension SettingsViewModel { case .addToDock: presentViewController(legacyViewProvider.addToDock, modal: true) case .sync: - pushViewController(legacyViewProvider.syncSettings) + pushViewController(legacyViewProvider.syncSettings(source: state.syncSource)) case .appIcon: pushViewController(legacyViewProvider.appIcon) case .unprotectedSites: pushViewController(legacyViewProvider.unprotectedSites) case .fireproofSites: pushViewController(legacyViewProvider.fireproofSites) diff --git a/DuckDuckGo/SyncAssets.xcassets/Share-Apple-24.imageset/Contents.json b/DuckDuckGo/SyncAssets.xcassets/Share-Apple-24.imageset/Contents.json new file mode 100644 index 0000000000..9735fecc5d --- /dev/null +++ b/DuckDuckGo/SyncAssets.xcassets/Share-Apple-24.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Share-Apple-24.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/SyncAssets.xcassets/Share-Apple-24.imageset/Share-Apple-24.svg b/DuckDuckGo/SyncAssets.xcassets/Share-Apple-24.imageset/Share-Apple-24.svg new file mode 100644 index 0000000000..3f2ee0ff56 --- /dev/null +++ b/DuckDuckGo/SyncAssets.xcassets/Share-Apple-24.imageset/Share-Apple-24.svg @@ -0,0 +1,4 @@ + + + + diff --git a/DuckDuckGo/SyncAssets.xcassets/Sync-App-Download-128.imageset/App-Download-128.svg b/DuckDuckGo/SyncAssets.xcassets/Sync-App-Download-128.imageset/App-Download-128.svg new file mode 100644 index 0000000000..e4eb6a89c9 --- /dev/null +++ b/DuckDuckGo/SyncAssets.xcassets/Sync-App-Download-128.imageset/App-Download-128.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/SyncAssets.xcassets/Sync-App-Download-128.imageset/Contents.json b/DuckDuckGo/SyncAssets.xcassets/Sync-App-Download-128.imageset/Contents.json new file mode 100644 index 0000000000..38dd2b21e5 --- /dev/null +++ b/DuckDuckGo/SyncAssets.xcassets/Sync-App-Download-128.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "App-Download-128.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/SyncAssets.xcassets/Sync-Downloads-24.imageset/Contents.json b/DuckDuckGo/SyncAssets.xcassets/Sync-Downloads-24.imageset/Contents.json new file mode 100644 index 0000000000..5651383d76 --- /dev/null +++ b/DuckDuckGo/SyncAssets.xcassets/Sync-Downloads-24.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Downloads-24.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/SyncAssets.xcassets/Sync-Downloads-24.imageset/Downloads-24.svg b/DuckDuckGo/SyncAssets.xcassets/Sync-Downloads-24.imageset/Downloads-24.svg new file mode 100644 index 0000000000..af3fe1f7c9 --- /dev/null +++ b/DuckDuckGo/SyncAssets.xcassets/Sync-Downloads-24.imageset/Downloads-24.svg @@ -0,0 +1,4 @@ + + + + diff --git a/DuckDuckGo/SyncPromoManager.swift b/DuckDuckGo/SyncPromoManager.swift new file mode 100644 index 0000000000..d2c58185b7 --- /dev/null +++ b/DuckDuckGo/SyncPromoManager.swift @@ -0,0 +1,94 @@ +// +// SyncPromoManager.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 Bookmarks +import BrowserServicesKit +import Common +import Core +import Persistence +import DDGSync + +protocol SyncPromoManaging { + func shouldPresentPromoFor(_ touchpoint: SyncPromoManager.Touchpoint, count: Int) -> Bool + func dismissPromoFor(_ touchpoint: SyncPromoManager.Touchpoint) + func resetPromos() +} + +final class SyncPromoManager: SyncPromoManaging { + + enum Touchpoint: String { + case bookmarks + case passwords + } + + private let featureFlagger: FeatureFlagger + private let syncService: DDGSyncing + + @UserDefaultsWrapper(key: .syncPromoBookmarksDismissed, defaultValue: nil) + private var syncPromoBookmarksDismissed: Date? + + @UserDefaultsWrapper(key: .syncPromoPasswordsDismissed, defaultValue: nil) + private var syncPromoPasswordsDismissed: Date? + + init(syncService: DDGSyncing, + featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger) { + self.featureFlagger = featureFlagger + self.syncService = syncService + } + + func shouldPresentPromoFor(_ touchpoint: Touchpoint, count: Int) -> Bool { + switch touchpoint { + case .bookmarks: + if featureFlagger.isFeatureOn(.syncPromotionBookmarks), + syncService.authState == .inactive, + featureFlagger.isFeatureOn(.sync), + syncPromoBookmarksDismissed == nil, + count > 0 { + return true + } + case .passwords: + if featureFlagger.isFeatureOn(.syncPromotionPasswords), + syncService.authState == .inactive, + featureFlagger.isFeatureOn(.sync), + syncPromoPasswordsDismissed == nil, + count > 0 { + return true + } + } + + return false + } + + func dismissPromoFor(_ touchpoint: Touchpoint) { + switch touchpoint { + case .bookmarks: + syncPromoBookmarksDismissed = Date() + case .passwords: + syncPromoPasswordsDismissed = Date() + } + + Pixel.fire(.syncPromoDismissed, withAdditionalParameters: ["source": touchpoint.rawValue]) + } + + func resetPromos() { + syncPromoBookmarksDismissed = nil + syncPromoPasswordsDismissed = nil + } +} diff --git a/DuckDuckGo/SyncPromoView.swift b/DuckDuckGo/SyncPromoView.swift new file mode 100644 index 0000000000..a77a410ad2 --- /dev/null +++ b/DuckDuckGo/SyncPromoView.swift @@ -0,0 +1,96 @@ +// +// SyncPromoView.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 SwiftUI +import DesignResourcesKit +import DuckUI + +struct SyncPromoView: View { + + let viewModel: SyncPromoViewModel + + var body: some View { + ZStack(alignment: .topTrailing) { + VStack(spacing: 8) { + Group { + Image(viewModel.image) + .scaledToFit() + Text(viewModel.title) + .padding(.top, 4) + .frame(maxWidth: .infinity) + .daxHeadline() + Text(viewModel.subtitle) + .daxSubheadRegular() + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 12) + + HStack { + Button { + viewModel.dismissButtonAction?() + } label: { + Text(viewModel.secondaryButtonTitle) + } + .buttonStyle(SecondaryFillButtonStyle(compact: true, fullWidth: false)) + + Button { + viewModel.primaryButtonAction?() + } label: { + Text(viewModel.primaryButtonTitle) + } + .buttonStyle(PrimaryButtonStyle(compact: true, fullWidth: false)) + + } + .padding(.top, 12) + .padding(.horizontal, 8) + } + .multilineTextAlignment(.center) + .padding(.vertical) + .padding(.horizontal, 8) + + VStack { + HStack { + Spacer() + Button { + viewModel.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) + .foregroundColor(Color(designSystemColor: .surface)) + ) + .padding(.horizontal, 20) + .padding(.bottom, 12) + } +} + +#Preview { + SyncPromoView(viewModel: SyncPromoViewModel()) +} diff --git a/DuckDuckGo/SyncPromoViewModel.swift b/DuckDuckGo/SyncPromoViewModel.swift new file mode 100644 index 0000000000..b21ae518cd --- /dev/null +++ b/DuckDuckGo/SyncPromoViewModel.swift @@ -0,0 +1,68 @@ +// +// SyncPromoViewModel.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 SwiftUI + +struct SyncPromoViewModel { + + var touchpointType: SyncPromoManager.Touchpoint = .bookmarks + + var primaryButtonAction: (() -> Void)? + var dismissButtonAction: (() -> Void)? + + var title: String { + switch touchpointType { + case .bookmarks: + UserText.syncPromoBookmarksTitle + case .passwords: + UserText.syncPromoPasswordsTitle + } + } + + var subtitle: String { + switch touchpointType { + case .bookmarks: + UserText.syncPromoBookmarksMessage + case .passwords: + UserText.syncPromoPasswordsMessage + } + } + + var image: String { + switch touchpointType { + default: + return "Sync-Start-96" + } + } + + var primaryButtonTitle: String { + switch touchpointType { + case .bookmarks, .passwords: + UserText.syncPromoConfirmAction + } + } + + var secondaryButtonTitle: String { + switch touchpointType { + case .bookmarks, .passwords: + UserText.syncPromoDismissAction + } + } +} diff --git a/DuckDuckGo/SyncSettingsViewController+PlatformLinks.swift b/DuckDuckGo/SyncSettingsViewController+PlatformLinks.swift new file mode 100644 index 0000000000..b534bf3971 --- /dev/null +++ b/DuckDuckGo/SyncSettingsViewController+PlatformLinks.swift @@ -0,0 +1,57 @@ +// +// SyncSettingsViewController+PlatformLinks.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 UIKit + +extension SyncSettingsViewController { + + func shareLink(for url: URL, with message: String, from rect: CGRect) { + let itemSource = ShareItemSource(url: url, message: message) + let activityViewController = UIActivityViewController(activityItems: [itemSource], applicationActivities: nil) + + if UIDevice.current.userInterfaceIdiom == .pad { + activityViewController.popoverPresentationController?.sourceView = UIApplication.shared.firstKeyWindow + activityViewController.popoverPresentationController?.sourceRect = rect + } + + present(activityViewController, animated: true, completion: nil) + } +} + +private class ShareItemSource: NSObject, UIActivityItemSource { + var url: URL + var message: String + + init(url: URL, message: String) { + self.url = url + self.message = message + } + + func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { + return url + } + + func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { + if activityType == .mail || activityType == .message { + return "\(message) \(url.absoluteString)" + } + return url + } + +} diff --git a/DuckDuckGo/SyncSettingsViewController+SyncDelegate.swift b/DuckDuckGo/SyncSettingsViewController+SyncDelegate.swift index ac544ba010..23bb29a134 100644 --- a/DuckDuckGo/SyncSettingsViewController+SyncDelegate.swift +++ b/DuckDuckGo/SyncSettingsViewController+SyncDelegate.swift @@ -144,7 +144,8 @@ extension SyncSettingsViewController: SyncManagementViewModelDelegate { self.dismissPresentedViewController() self.showPreparingSync() try await self.syncService.createAccount(deviceName: self.deviceName, deviceType: self.deviceType) - Pixel.fire(pixel: .syncSignupDirect, includedParameters: [.appVersion]) + let additionalParameters = self.source.map { ["source": $0] } ?? [:] + try await Pixel.fire(pixel: .syncSignupDirect, withAdditionalParameters: additionalParameters, includedParameters: [.appVersion]) self.rootView.model.syncEnabled(recoveryCode: self.recoveryCode) self.refreshDevices() self.navigationController?.topViewController?.dismiss(animated: true, completion: self.showRecoveryPDF) @@ -210,13 +211,39 @@ extension SyncSettingsViewController: SyncManagementViewModelDelegate { } func showDeviceConnected() { + guard let viewModel = viewModel else { + return + } + let controller = UIHostingController( - rootView: DeviceConnectedView()) + rootView: DeviceConnectedView(model: viewModel)) navigationController?.present(controller, animated: true) { [weak self] in self?.rootView.model.syncEnabled(recoveryCode: self!.recoveryCode) } } + func showOtherPlatformLinks() { + guard let viewModel = viewModel else { + return + } + + let controller = UIHostingController(rootView: PlatformLinksView(model: viewModel, source: .activating)) + navigationController?.pushViewController(controller, animated: true) + } + + func fireOtherPlatformLinksPixel(event: SyncSettingsViewModel.PlatformLinksPixelEvent, with source: SyncSettingsViewModel.PlatformLinksPixelSource) { + let params = ["source": source.rawValue] + + switch event { + case .appear: + Pixel.fire(.syncGetOtherDevices, withAdditionalParameters: params) + case .copy: + Pixel.fire(.syncGetOtherDevicesCopy, withAdditionalParameters: params) + case .share: + Pixel.fire(.syncGetOtherDevicesShare, withAdditionalParameters: params) + } + } + func showPreparingSync() { let controller = UIHostingController(rootView: PreparingToSyncView()) navigationController?.present(controller, animated: true) diff --git a/DuckDuckGo/SyncSettingsViewController.swift b/DuckDuckGo/SyncSettingsViewController.swift index 69b5a6b4a0..d8c64c414a 100644 --- a/DuckDuckGo/SyncSettingsViewController.swift +++ b/DuckDuckGo/SyncSettingsViewController.swift @@ -58,6 +58,8 @@ class SyncSettingsViewController: UIHostingController { let syncPausedStateManager: any SyncPausedStateManaging var viewModel: SyncSettingsViewModel? + var source: String? + var onConfirmSyncDisable: (() -> Void)? var onConfirmAndDeleteAllData: (() -> Void)? @@ -67,12 +69,14 @@ class SyncSettingsViewController: UIHostingController { syncBookmarksAdapter: SyncBookmarksAdapter, syncCredentialsAdapter: SyncCredentialsAdapter, appSettings: AppSettings = AppDependencyProvider.shared.appSettings, - syncPausedStateManager: any SyncPausedStateManaging + syncPausedStateManager: any SyncPausedStateManaging, + source: String? = nil ) { self.syncService = syncService self.syncBookmarksAdapter = syncBookmarksAdapter self.syncCredentialsAdapter = syncCredentialsAdapter self.syncPausedStateManager = syncPausedStateManager + self.source = source let viewModel = SyncSettingsViewModel( isOnDevEnvironment: { syncService.serverEnvironment == .development }, @@ -362,7 +366,8 @@ extension SyncSettingsViewController: ScanOrPasteCodeViewModelDelegate { if syncService.account == nil { do { try await syncService.createAccount(deviceName: deviceName, deviceType: deviceType) - Pixel.fire(pixel: .syncSignupConnect, includedParameters: [.appVersion]) + let additionalParameters = source.map { ["source": $0] } ?? [:] + try await Pixel.fire(pixel: .syncSignupConnect, withAdditionalParameters: additionalParameters, includedParameters: [.appVersion]) self.dismissVCAndShowRecoveryPDF() shouldShowSyncEnabled = false rootView.model.syncEnabled(recoveryCode: recoveryCode) diff --git a/DuckDuckGo/UserAuthenticator.swift b/DuckDuckGo/UserAuthenticator.swift index b49310c02c..b906995695 100644 --- a/DuckDuckGo/UserAuthenticator.swift +++ b/DuckDuckGo/UserAuthenticator.swift @@ -40,7 +40,6 @@ class UserAuthenticator { private var context = LAContext() private var reason: String @Published private(set) var state = AuthenticationState.loggedOut - private var authenticationCallTimestamps: [Date] = [] init(reason: String) { self.reason = reason @@ -80,8 +79,6 @@ class UserAuthenticator { completion?(.failedToAuthenticate) } } - - self?.monitorAuthenticationCalls() } } else { state = .notAvailable @@ -93,14 +90,4 @@ class UserAuthenticator { context.invalidate() } - func monitorAuthenticationCalls() { - authenticationCallTimestamps.append(Date()) - - // we only care about timestamps from the last 10 seconds - authenticationCallTimestamps = authenticationCallTimestamps.filter { Date().timeIntervalSince($0) <= 10 } - - if authenticationCallTimestamps.count > 2 { - DailyPixel.fire(pixel: .autofillMultipleAuthCallsTriggered) - } - } } diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 9e60064df9..179a31c84e 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -114,6 +114,7 @@ public struct UserText { public static let favoriteMenuEdit = NSLocalizedString("favorite.menu.edit", value: "Edit", comment: "") public static let emptyBookmarks = NSLocalizedString("empty.bookmarks", value: "No bookmarks added yet", comment: "Empty list state placholder") + public static let bookmarkSearchBarPlaceholder = NSLocalizedString("bookmark.searchbar.placeholder", value: "Search", comment: "Placeholder in the bookmarks search bar") public static let noMatchesFound = NSLocalizedString("empty.search", value: "No matches found", comment: "Empty search placeholder on bookmarks search") public static let bookmarkTitlePlaceholder = NSLocalizedString("bookmark.title.placeholder", value: "Website title", comment: "Placeholder in the add bookmark form") @@ -937,6 +938,14 @@ But if you *do* want a peek under the hood, you can find more information about static let syncUnavailableMessage = NSLocalizedString("sync.warning.data.syncing.disabled", value: "Sorry, but Sync & Backup is currently unavailable. Please try again later.", comment: "Data syncing unavailable warning message") static let syncUnavailableMessageUpgradeRequired = NSLocalizedString("sync.warning.data.syncing.disabled.upgrade.required", value: "Sorry, but Sync & Backup is no longer available in this app version. Please update DuckDuckGo to the latest version to continue.", comment: "Data syncing unavailable warning message") + // Mark: Sync Promotion + public static let syncPromoBookmarksTitle = NSLocalizedString("sync.promo.bookmarks.title", value:"Sync & Back Up Your Bookmarks", comment: "Title for the Sync Promotion banner") + public static let syncPromoPasswordsTitle = NSLocalizedString("sync.promo.passwords.title", value:"Sync & Back Up Your Passwords", comment: "Title for the Sync Promotion banner") + public static let syncPromoBookmarksMessage = NSLocalizedString("sync.promo.bookmarks.message", value:"No account needed. End-to-end encryption means nobody but you can see your bookmarks, not even us.", comment: "Message for the Sync Promotion banner when user has bookmarks that can be synced") + public static let syncPromoPasswordsMessage = NSLocalizedString("sync.promo.passwords.message", value:"No account needed. End-to-end encryption means nobody but you can see your passwords, not even us.", comment: "Message for the Sync Promotion banner when user has passwords that can be synced") + public static let syncPromoConfirmAction = NSLocalizedString("sync.promo.confirm.action", value:"Set Up Sync", comment: "Title for a button in the Sync Promotion banner to set up Sync") + public static let syncPromoDismissAction = NSLocalizedString("sync.promo.dismiss.action", value:"No Thanks", comment: "Title for a button in the Sync Promotion banner to dismiss Sync promotion banner") + static let preemptiveCrashTitle = NSLocalizedString("error.preemptive-crash.title", value: "App issue detected", comment: "Alert title") static let preemptiveCrashBody = NSLocalizedString("error.preemptive-crash.body", value: "Looks like there's an issue with the app and it needs to close. Please reopen to continue.", comment: "Alert message") static let preemptiveCrashAction = NSLocalizedString("error.preemptive-crash.action", value: "Close App", comment: "Button title that is shutting down the app") @@ -948,7 +957,7 @@ But if you *do* want a peek under the hood, you can find more information about static let emailProtectionSignInTitle = NSLocalizedString("error.email-protection-sign-in.title", value: "Email Protection Error", comment: "Alert title") static let emailProtectionSignInBody = NSLocalizedString("error.email-protection-sign-in.body", value: "Sorry, please sign in again to re-enable Email Protection features on this browser.", comment: "Alert message") static let emailProtectionSignInAction = NSLocalizedString("error.email-protection-sign-in.action", value: "Sign In", comment: "Button title to Sign In") - + // MARK: VPN static let networkProtectionNotificationsTitle = NSLocalizedString("network.protection.notification.title", value: "DuckDuckGo", comment: "The title of the notifications shown from Network Protection") diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 5a1673572b..a68ea3a7d8 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -680,6 +680,9 @@ /* More options button text */ "bookmark.moreButton" = "More"; +/* Placeholder in the bookmarks search bar */ +"bookmark.searchbar.placeholder" = "Search"; + /* Placeholder in the add bookmark form */ "bookmark.title.placeholder" = "Website title"; @@ -2556,6 +2559,24 @@ But if you *do* want a peek under the hood, you can find more information about /* Title of the dialog to confirm deleting Sync server data */ "sync.delete.all.confirm.title" = "Delete Server Data?"; +/* Message for the Sync Promotion banner when user has bookmarks that can be synced */ +"sync.promo.bookmarks.message" = "No account needed. End-to-end encryption means nobody but you can see your bookmarks, not even us."; + +/* Title for the Sync Promotion banner */ +"sync.promo.bookmarks.title" = "Sync & Back Up Your Bookmarks"; + +/* Title for a button in the Sync Promotion banner to set up Sync */ +"sync.promo.confirm.action" = "Set Up Sync"; + +/* Title for a button in the Sync Promotion banner to dismiss Sync promotion banner */ +"sync.promo.dismiss.action" = "No Thanks"; + +/* Message for the Sync Promotion banner when user has passwords that can be synced */ +"sync.promo.passwords.message" = "No account needed. End-to-end encryption means nobody but you can see your passwords, not even us."; + +/* Title for the Sync Promotion banner */ +"sync.promo.passwords.title" = "Sync & Back Up Your Passwords"; + /* Caption for a button to remove device from Sync */ "sync.remove-device.action" = "Remove"; diff --git a/DuckDuckGoTests/AutofillLoginListViewModelTests.swift b/DuckDuckGoTests/AutofillLoginListViewModelTests.swift index e3debfceef..f4f0880d8e 100644 --- a/DuckDuckGoTests/AutofillLoginListViewModelTests.swift +++ b/DuckDuckGoTests/AutofillLoginListViewModelTests.swift @@ -34,6 +34,7 @@ class AutofillLoginListViewModelTests: XCTestCase { private let vault = (try? MockSecureVaultFactory.makeVault(reporter: nil))! private var manager: AutofillNeverPromptWebsitesManager! private var cancellables: Set = [] + var syncService: MockDDGSyncing! private let configEnabled = """ { @@ -72,11 +73,13 @@ class AutofillLoginListViewModelTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() manager = AutofillNeverPromptWebsitesManager(secureVault: vault) + syncService = MockDDGSyncing(authState: .inactive, scheduler: CapturingScheduler(), isSyncInProgress: false) } override func tearDownWithError() throws { manager = nil cancellables.removeAll() + syncService = nil try super.tearDownWithError() } @@ -99,7 +102,7 @@ class AutofillLoginListViewModelTests: XCTestCase { SecureVaultModels.WebsiteAccount(id: accountIdToDelete, title: nil, username: "", domain: "testsite.com", created: Date(), lastUpdated: Date()) ] - let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) let tableContentsToDelete = model.tableContentsToDelete(accountId: accountIdToDelete) XCTAssertEqual(tableContentsToDelete.sectionsToDelete.count, 1) XCTAssertEqual(tableContentsToDelete.rowsToDelete.count, 0) @@ -113,7 +116,7 @@ class AutofillLoginListViewModelTests: XCTestCase { SecureVaultModels.WebsiteAccount(id: "3", title: nil, username: "", domain: "testsite3.com", created: Date(), lastUpdated: Date()) ] - let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) let tableContentsToDelete = model.tableContentsToDelete(accountId: accountIdToDelete) XCTAssertEqual(tableContentsToDelete.sectionsToDelete.count, 0) XCTAssertEqual(tableContentsToDelete.rowsToDelete.count, 1) @@ -126,7 +129,7 @@ class AutofillLoginListViewModelTests: XCTestCase { SecureVaultModels.WebsiteAccount(id: accountIdToDelete, title: nil, username: "", domain: testDomain, created: Date(), lastUpdated: Date()) ] - let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, currentTabUrl: URL(string: "https://\(testDomain)")) + let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, currentTabUrl: URL(string: "https://\(testDomain)"), syncService: syncService) let tableContentsToDelete = model.tableContentsToDelete(accountId: accountIdToDelete) XCTAssertEqual(tableContentsToDelete.sectionsToDelete.count, 2) XCTAssertEqual(tableContentsToDelete.rowsToDelete.count, 0) @@ -141,7 +144,7 @@ class AutofillLoginListViewModelTests: XCTestCase { SecureVaultModels.WebsiteAccount(id: "3", title: nil, username: "", domain: "testsite3.com", created: Date(), lastUpdated: Date()) ] - let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, currentTabUrl: URL(string: "https://\(testDomain)")) + let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, currentTabUrl: URL(string: "https://\(testDomain)"), syncService: syncService) let tableContentsToDelete = model.tableContentsToDelete(accountId: accountIdToDelete) XCTAssertEqual(tableContentsToDelete.sectionsToDelete.count, 1) XCTAssertEqual(tableContentsToDelete.rowsToDelete.count, 1) @@ -156,7 +159,7 @@ class AutofillLoginListViewModelTests: XCTestCase { SecureVaultModels.WebsiteAccount(id: "3", title: nil, username: "", domain: "testsite3.com", created: Date(), lastUpdated: Date()) ] - let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, currentTabUrl: URL(string: "https://\(testDomain)")) + let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, currentTabUrl: URL(string: "https://\(testDomain)"), syncService: syncService) let tableContentsToDelete = model.tableContentsToDelete(accountId: accountIdToDelete) XCTAssertEqual(tableContentsToDelete.sectionsToDelete.count, 0) XCTAssertEqual(tableContentsToDelete.rowsToDelete.count, 2) @@ -169,7 +172,7 @@ class AutofillLoginListViewModelTests: XCTestCase { SecureVaultModels.WebsiteAccount(id: "3", title: nil, username: "", domain: "testsite3.com", created: Date(), lastUpdated: Date()) ] let model - = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) XCTAssertEqual(model.sections.count, 2) XCTAssertEqual(model.rowsInSection(1), 3) @@ -185,7 +188,7 @@ class AutofillLoginListViewModelTests: XCTestCase { SecureVaultModels.WebsiteAccount(id: "3", title: nil, username: "", domain: "testsite3.com", created: Date(), lastUpdated: Date()) ] let model - = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) XCTAssertEqual(model.sections.count, 2) XCTAssertEqual(model.rowsInSection(1), 3) @@ -204,7 +207,7 @@ class AutofillLoginListViewModelTests: XCTestCase { SecureVaultModels.WebsiteAccount(id: "1", title: nil, username: "", domain: "testsite.com", created: Date(), lastUpdated: Date()) ] let model - = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) XCTAssertEqual(model.sections.count, 2) XCTAssertEqual(model.rowsInSection(1), 1) @@ -226,7 +229,7 @@ class AutofillLoginListViewModelTests: XCTestCase { ] let testDomain = "testsite.com" let model - = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, currentTabUrl: URL(string: "https://\(testDomain)"), autofillNeverPromptWebsitesManager: manager, privacyConfig: makePrivacyConfig(from: configDisabled)) + = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, currentTabUrl: URL(string: "https://\(testDomain)"), autofillNeverPromptWebsitesManager: manager, privacyConfig: makePrivacyConfig(from: configDisabled), syncService: syncService) XCTAssertEqual(model.sections.count, 3) XCTAssertEqual(model.rowsInSection(1), 1) XCTAssertEqual(model.rowsInSection(2), 3) @@ -247,7 +250,7 @@ class AutofillLoginListViewModelTests: XCTestCase { SecureVaultModels.WebsiteAccount(id: "1", title: nil, username: "", domain: "testsite.com", created: Date(), lastUpdated: Date()) ] let model - = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) XCTAssertEqual(model.sections.count, 2) @@ -261,8 +264,8 @@ class AutofillLoginListViewModelTests: XCTestCase { SecureVaultModels.WebsiteAccount(id: "1", title: nil, username: "", domain: "testsite.com", created: Date(), lastUpdated: Date()) ] let model - = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) - + = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) + XCTAssertEqual(model.sections.count, 2) model.isSearching = true @@ -283,7 +286,7 @@ class AutofillLoginListViewModelTests: XCTestCase { SecureVaultModels.WebsiteAccount(id: "2", title: nil, username: "", domain: "testsite2.com", created: Date(), lastUpdated: Date()), ] let model - = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) model.isEditing = true model.accountsCountPublisher.sink { count in @@ -305,7 +308,7 @@ class AutofillLoginListViewModelTests: XCTestCase { } let model - = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) XCTAssertEqual(model.sections.count, 2) XCTAssertEqual(model.rowsInSection(0), 1) XCTAssertEqual(model.rowsInSection(1), 1) @@ -332,7 +335,7 @@ class AutofillLoginListViewModelTests: XCTestCase { } let model - = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) XCTAssertEqual(model.sections.count, 2) XCTAssertEqual(model.rowsInSection(1), 3) XCTAssertEqual(vault.storedAccounts.count, 3) @@ -349,14 +352,14 @@ class AutofillLoginListViewModelTests: XCTestCase { func testWhenNoNeverPromptWebsitesSavedThenNeverPromptSectionIsNotShown() { XCTAssertTrue(manager.deleteAllNeverPromptWebsites()) - let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) XCTAssertEqual(model.rowsInSection(0), 1) } func testWhenOneNeverPromptWebsiteSavedThenNeverPromptSectionIsShown() { XCTAssertTrue(manager.deleteAllNeverPromptWebsites()) XCTAssertNoThrow(try manager.saveNeverPromptWebsite("example.com")) - let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) XCTAssertEqual(model.rowsInSection(0), 2) } @@ -368,7 +371,7 @@ class AutofillLoginListViewModelTests: XCTestCase { XCTAssertNoThrow(try manager.saveNeverPromptWebsite("daxisawesome.com")) XCTAssertNoThrow(try manager.saveNeverPromptWebsite("123domain.com")) - let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager) + let model = AutofillLoginListViewModel(appSettings: appSettings, tld: tld, secureVault: vault, autofillNeverPromptWebsitesManager: manager, syncService: syncService) XCTAssertEqual(model.rowsInSection(0), 2) } @@ -386,7 +389,8 @@ class AutofillLoginListViewModelTests: XCTestCase { currentTabUid: "1", autofillNeverPromptWebsitesManager: manager, privacyConfig: makePrivacyConfig(from: configDisabled), - keyValueStore: MockKeyValueStore()) + keyValueStore: MockKeyValueStore(), + syncService: syncService) XCTAssertFalse(model.shouldShowBreakageReporter()) } @@ -405,7 +409,8 @@ class AutofillLoginListViewModelTests: XCTestCase { currentTabUid: "1", autofillNeverPromptWebsitesManager: manager, privacyConfig: makePrivacyConfig(from: configEnabled), - keyValueStore: MockKeyValueStore()) + keyValueStore: MockKeyValueStore(), + syncService: syncService) XCTAssertFalse(model.shouldShowBreakageReporter()) } @@ -424,7 +429,8 @@ class AutofillLoginListViewModelTests: XCTestCase { currentTabUid: "1", autofillNeverPromptWebsitesManager: manager, privacyConfig: makePrivacyConfig(from: configEnabled), - keyValueStore: MockKeyValueStore()) + keyValueStore: MockKeyValueStore(), + syncService: syncService) XCTAssertFalse(model.shouldShowBreakageReporter()) } @@ -443,7 +449,8 @@ class AutofillLoginListViewModelTests: XCTestCase { currentTabUid: "1", autofillNeverPromptWebsitesManager: manager, privacyConfig: makePrivacyConfig(from: configEnabled), - keyValueStore: MockKeyValueStore()) + keyValueStore: MockKeyValueStore(), + syncService: syncService) XCTAssertFalse(model.shouldShowBreakageReporter()) } @@ -465,7 +472,8 @@ class AutofillLoginListViewModelTests: XCTestCase { currentTabUid: "1", autofillNeverPromptWebsitesManager: manager, privacyConfig: makePrivacyConfig(from: configEnabled), - keyValueStore: MockKeyValueStore()) + keyValueStore: MockKeyValueStore(), + syncService: syncService) let identifier = currentTabUrl!.privacySafeDomainIdentifier model.breakageReporter.persistencyManager.set(value: "2024-07-16", forKey: identifier!, expiryDate: Date()) @@ -488,7 +496,8 @@ class AutofillLoginListViewModelTests: XCTestCase { currentTabUid: "1", autofillNeverPromptWebsitesManager: manager, privacyConfig: makePrivacyConfig(from: configEnabled), - keyValueStore: MockKeyValueStore()) + keyValueStore: MockKeyValueStore(), + syncService: syncService) XCTAssertTrue(model.shouldShowBreakageReporter()) } @@ -508,7 +517,8 @@ class AutofillLoginListViewModelTests: XCTestCase { currentTabUid: "1", autofillNeverPromptWebsitesManager: manager, privacyConfig: makePrivacyConfig(from: configEnabled), - keyValueStore: MockKeyValueStore()) + keyValueStore: MockKeyValueStore(), + syncService: syncService) let identifier = currentTabUrl!.privacySafeDomainIdentifier model.breakageReporter.persistencyManager.set(value: "2024-01-01", forKey: identifier!, expiryDate: Date()) diff --git a/DuckDuckGoTests/SyncPromoManagerTests.swift b/DuckDuckGoTests/SyncPromoManagerTests.swift new file mode 100644 index 0000000000..c4bec9cb0f --- /dev/null +++ b/DuckDuckGoTests/SyncPromoManagerTests.swift @@ -0,0 +1,179 @@ +// +// SyncPromoManagerTests.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 +@testable import BrowserServicesKit +@testable import Core +@testable import DDGSync +@testable import DuckDuckGo + +final class SyncPromoManagerTests: XCTestCase { + + let testGroupName = "test" + var customSuite: UserDefaults! + var syncService: MockDDGSyncing! + + override func setUpWithError() throws { + try super.setUpWithError() + + customSuite = UserDefaults(suiteName: testGroupName) + customSuite.removePersistentDomain(forName: testGroupName) + syncService = MockDDGSyncing(authState: .inactive, scheduler: CapturingScheduler(), isSyncInProgress: false) + UserDefaults.app = customSuite + } + + override func tearDownWithError() throws { + UserDefaults.app = .standard + syncService = nil + + super.tearDown() + } + + func testWhenAllConditionsMetThenShouldPresentPromoForBookmarks() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.syncPromotionBookmarks, .sync]) + syncService.authState = .inactive + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + + XCTAssertTrue(syncPromoManager.shouldPresentPromoFor(.bookmarks, count: 1)) + } + + + func testWhenSyncPromotionBookmarksFeatureFlagDisabledThenShouldNotPresentPromoForBookmarks() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.sync]) + syncService.authState = .inactive + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + + XCTAssertFalse(syncPromoManager.shouldPresentPromoFor(.bookmarks, count: 1)) + } + + func testWhenSyncFeatureFlagDisabledThenShouldNotPresentPromoForBookmarks() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.syncPromotionBookmarks]) + syncService.authState = .inactive + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + + XCTAssertFalse(syncPromoManager.shouldPresentPromoFor(.bookmarks, count: 1)) + } + + func testWhenSyncServiceAuthStateActiveThenShouldNotPresentPromoForBookmarks() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.syncPromotionBookmarks, .sync]) + syncService.authState = .active + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + + XCTAssertFalse(syncPromoManager.shouldPresentPromoFor(.bookmarks, count: 1)) + } + + func testWhenSyncPromoBookmarksDismissedThenShouldNotPresentPromoForBookmarks() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.syncPromotionBookmarks, .sync]) + syncService.authState = .inactive + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + syncPromoManager.dismissPromoFor(.bookmarks) + + XCTAssertFalse(syncPromoManager.shouldPresentPromoFor(.bookmarks, count: 1)) + } + + func testWhenBookmarksCountIsZeroThenShouldNotPresentPromoForBookmarks() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.syncPromotionBookmarks, .sync]) + syncService.authState = .inactive + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + + XCTAssertFalse(syncPromoManager.shouldPresentPromoFor(.bookmarks, count: 0)) + } + + func testWhenAllConditionsMetThenShouldPresentPromoForPasswords() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.syncPromotionPasswords, .sync]) + syncService.authState = .inactive + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + + XCTAssertTrue(syncPromoManager.shouldPresentPromoFor(.passwords, count: 1)) + } + + func testWhenSyncPromotionPasswordsFeatureFlagDisabledThenShouldNotPresentPromoForPasswords() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.sync]) + syncService.authState = .inactive + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + + XCTAssertFalse(syncPromoManager.shouldPresentPromoFor(.passwords, count: 1)) + } + + func testWhenSyncFeatureFlagDisabledThenShouldNotPresentPromoForPasswords() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.syncPromotionPasswords]) + syncService.authState = .inactive + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + + XCTAssertFalse(syncPromoManager.shouldPresentPromoFor(.passwords, count: 1)) + } + + func testWhenSyncServiceAuthStateActiveThenShouldNotPresentPromoForPasswords() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.syncPromotionPasswords, .sync]) + syncService.authState = .active + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + + XCTAssertFalse(syncPromoManager.shouldPresentPromoFor(.passwords, count: 1)) + } + + func testWhenSyncPromoPasswordsDismissedThenShouldNotPresentPromoForPasswords() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.syncPromotionPasswords, .sync]) + syncService.authState = .inactive + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + syncPromoManager.dismissPromoFor(.passwords) + + XCTAssertFalse(syncPromoManager.shouldPresentPromoFor(.passwords, count: 1)) + } + + func testWhenPasswordsCountIsZeroThenShouldNotPresentPromoForPasswords() { + let featureFlagger = createFeatureFlagger(withFeatureFlagsEnabled: [.syncPromotionPasswords, .sync]) + syncService.authState = .inactive + + let syncPromoManager = SyncPromoManager(syncService: syncService, featureFlagger: featureFlagger) + syncPromoManager.resetPromos() + + XCTAssertFalse(syncPromoManager.shouldPresentPromoFor(.passwords, count: 0)) + } + + // MARK: - Mock Creation + + private func createFeatureFlagger(withFeatureFlagsEnabled featureFlags: [FeatureFlag]) -> FeatureFlagger { + let mockFeatureFlagger = MockFeatureFlagger() + mockFeatureFlagger.enabledFeatureFlags.append(contentsOf: featureFlags) + return mockFeatureFlagger + } + +} diff --git a/DuckDuckGoTests/SyncUI/SyncManagementViewModelTests.swift b/DuckDuckGoTests/SyncUI/SyncManagementViewModelTests.swift index fae1e40a96..af65520b66 100644 --- a/DuckDuckGoTests/SyncUI/SyncManagementViewModelTests.swift +++ b/DuckDuckGoTests/SyncUI/SyncManagementViewModelTests.swift @@ -22,7 +22,7 @@ import XCTest /// To be fleshed out when UI is settled class SyncManagementViewModelTests: XCTestCase, SyncManagementViewModelDelegate { - + fileprivate var monitor = Monitor() var syncBookmarksPausedTitle: String? = "syncBookmarksPausedTitle" var syncCredentialsPausedTitle: String? = "syncCredentialsPausedTitle" @@ -245,6 +245,19 @@ class SyncManagementViewModelTests: XCTestCase, SyncManagementViewModelDelegate monitor.incrementCalls(function: #function.cleaningFunctionName()) } + func showOtherPlatformLinks() { + monitor.incrementCalls(function: #function.cleaningFunctionName()) + } + + func fireOtherPlatformLinksPixel(event: SyncUI.SyncSettingsViewModel.PlatformLinksPixelEvent, with source: SyncUI.SyncSettingsViewModel.PlatformLinksPixelSource) { + monitor.incrementCalls(function: #function.cleaningFunctionName()) + } + + func shareLink(for url: URL, with message: String, from rect: CGRect) { + monitor.incrementCalls(function: #function.cleaningFunctionName()) + } + + } // MARK: An idea... can be made more public if works out diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Resources/en.lproj/Localizable.strings b/LocalPackages/SyncUI/Sources/SyncUI/Resources/en.lproj/Localizable.strings index cd0eb95080..e76789b263 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Resources/en.lproj/Localizable.strings +++ b/LocalPackages/SyncUI/Sources/SyncUI/Resources/en.lproj/Localizable.strings @@ -40,6 +40,9 @@ /* Connect With Server Sheet - Title */ "connect.with.server.sheet.title" = "Sync and Back Up This Device"; +/* Standard Buttons - Copy Button */ +"copy.button" = "Copy"; + /* Do not translate - stringsdict entry */ "credentials.invalid.objects.present.description" = "credentials.invalid.objects.present.description"; @@ -49,6 +52,9 @@ /* Delete Server Data - Button */ "delete.server.data" = "Turn Off and Delete Server Data..."; +/* Device SyncedSheet Button to go get DuckDuckGo on other devices */ +"device.synced.sheet.button.get.other.devices" = "Get DuckDuckGo on Other Devices"; + /* Device SyncedSheet - Title */ "device.synced.sheet.title" = "Your data is synced!"; @@ -202,6 +208,24 @@ /* Link label for syncing and backing up the device */ "sync.and.backup.this.device.link" = "Sync and Back Up This Device"; +/* Button to get DuckDuckGo on other devices */ +"sync.get.other.devices" = "Get DuckDuckGo on Other Devices"; + +/* Button title to share link for downloading DuckDuckGo on other devices */ +"sync.get.other.devices.card.button.title" = "Share Download Link"; + +/* Message before share link for downloading DuckDuckGo on other devices */ +"sync.get.other.devices.card.message" = "To download DuckDuckGo on desktop or another mobile device, visit:"; + +/* Title of card with share links for users to download DuckDuckGo on other devices */ +"sync.get.other.devices.card.title" = "Get DuckDuckGo on other devices to sync with this one"; + +/* Title of screen with share links for users to download DuckDuckGo on other devices */ +"sync.get.other.devices.screen.title" = "Get DuckDuckGo"; + +/* Message included when sharing a url via the system share sheet */ +"sync.get.other.devices.share.link.message" = "Install the DuckDuckGo browser on your devices to start securely syncing your bookmarks and passwords:"; + /* Sync Menu Path */ "sync.menu.path" = "Settings > Sync & Backup > Sync With Another Device"; diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift index df85c8860b..fa462f7c7e 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/SyncSettingsViewModel.swift @@ -39,6 +39,9 @@ public protocol SyncManagementViewModelDelegate: AnyObject { func updateOptions() func launchBookmarksViewController() func launchAutofillViewController() + func showOtherPlatformLinks() + func fireOtherPlatformLinksPixel(event: SyncSettingsViewModel.PlatformLinksPixelEvent, with source: SyncSettingsViewModel.PlatformLinksPixelSource) + func shareLink(for url: URL, with message: String, from rect: CGRect) var syncBookmarksPausedTitle: String? { get } var syncCredentialsPausedTitle: String? { get } @@ -82,6 +85,18 @@ public class SyncSettingsViewModel: ObservableObject { case valid } + public enum PlatformLinksPixelEvent { + case appear + case copy + case share + } + + public enum PlatformLinksPixelSource: String { + case notActivated = "not_activated" + case activating + case activated + } + @Published public var isSyncEnabled = false { didSet { if !isSyncEnabled { @@ -231,6 +246,18 @@ public class SyncSettingsViewModel: ObservableObject { delegate?.launchAutofillViewController() } + public func shareLinkPressed(for url: URL, with message: String, from rect: CGRect) { + delegate?.shareLink(for: url, with: message, from: rect) + } + + public func showOtherPlatformsPressed() { + delegate?.showOtherPlatformLinks() + } + + public func fireOtherPlatformLinksPixel(for event: PlatformLinksPixelEvent, source: PlatformLinksPixelSource) { + delegate?.fireOtherPlatformLinksPixel(event: event, with: source) + } + public func recoverSyncDataPressed() { Task { @MainActor in if await commonAuthenticate() { diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/DeviceConnectedView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/DeviceConnectedView.swift index aaf57001e9..54add03ed4 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/DeviceConnectedView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/DeviceConnectedView.swift @@ -22,9 +22,13 @@ import DuckUI public struct DeviceConnectedView: View { + var model: SyncSettingsViewModel + @Environment(\.presentationMode) var presentation - public init() {} + public init(model: SyncSettingsViewModel) { + self.model = model + } @ViewBuilder func deviceSyncedView() -> some View { @@ -40,12 +44,22 @@ public struct DeviceConnectedView: View { .padding(.horizontal, 20) .padding(.top, 56) } foregroundContent: { - Button { - presentation.wrappedValue.dismiss() - } label: { - Text(UserText.doneButton) + VStack { + Button { + presentation.wrappedValue.dismiss() + } label: { + Text(UserText.doneButton) + } + .buttonStyle(PrimaryButtonStyle()) + + Button { + presentation.wrappedValue.dismiss() + model.showOtherPlatformsPressed() + } label: { + Text(UserText.deviceSyncedSheetGetOnOtherDevicesButton) + } + .buttonStyle(GhostButtonStyle()) } - .buttonStyle(PrimaryButtonStyle()) .frame(maxWidth: 360) .padding(.horizontal, 30) } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/Internal/UserText.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/Internal/UserText.swift index b6358b37ea..7c142440f9 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/Internal/UserText.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/Internal/UserText.swift @@ -34,6 +34,8 @@ public struct UserText { static let syncAndBackUpThisDeviceLink = NSLocalizedString("sync.and.backup.this.device.link", bundle: Bundle.module, value: "Sync and Back Up This Device", comment: "Link label for syncing and backing up the device") static let recoverSyncedDataLink = NSLocalizedString("recover.synced.data.link", bundle: Bundle.module, value: "Recover Synced Data", comment: "Link label for recovering synced data") static let otherOptionsSectionHeader = NSLocalizedString("other.options.section.header", bundle: Bundle.module, value: "Other Options", comment: "Section header for other syncing options") + // Other Platforms + static let syncGetOnOtherDevices = NSLocalizedString("sync.get.other.devices", value: "Get DuckDuckGo on Other Devices", comment: "Button to get DuckDuckGo on other devices") // Sync Enabled View // Turn Sync Off @@ -93,6 +95,7 @@ public struct UserText { // Device Synced Sheet static let deviceSyncedSheetTitle = NSLocalizedString("device.synced.sheet.title", bundle: Bundle.module, value: "Your data is synced!", comment: "Device SyncedSheet - Title") + static let deviceSyncedSheetGetOnOtherDevicesButton = NSLocalizedString("device.synced.sheet.button.get.other.devices", value: "Get DuckDuckGo on Other Devices", comment: "Device SyncedSheet Button to go get DuckDuckGo on other devices") // Recover Synced Data Sheet static let recoverSyncedDataTitle = NSLocalizedString("recover.synced.data.sheet.title", bundle: Bundle.module, value: "Recover Synced Data", comment: "Recover Synced Data Sheet - Title") @@ -159,6 +162,7 @@ public struct UserText { static let backButton = NSLocalizedString("back.button", bundle: Bundle.module, value: "Back", comment: "Standard Buttons - Back Button") static let pasteButton = NSLocalizedString("paste.button", bundle: Bundle.module, value: "Paste", comment: "Standard Buttons - Paste Button") static let notNowButton = NSLocalizedString("not.now.button", bundle: Bundle.module, value: "Not Now", comment: "Standard Buttons - Not Now Button") + static let copyButton = NSLocalizedString("copy.button", bundle: Bundle.module, value: "Copy", comment: "Standard Buttons - Copy Button") // Fetch favicons static let fetchFaviconsOnboardingTitle = NSLocalizedString("fetch.favicons.onboarding.title", bundle: Bundle.module, value: "Download Missing Icons?", comment: "Fetch Favicons Onboarding - Title") @@ -169,4 +173,12 @@ public struct UserText { static let syncUnavailableTitle = NSLocalizedString("sync.warning.sync.unavailable", bundle: Bundle.module, value: "Sync & Backup is Unavailable", comment: "Title of the warning message") static let syncUnavailableMessage = NSLocalizedString("sync.warning.data.syncing.disabled", bundle: Bundle.module, value: "Sorry, but Sync & Backup is currently unavailable. Please try again later.", comment: "Data syncing unavailable warning message") static let syncUnavailableMessageUpgradeRequired = NSLocalizedString("sync.warning.data.syncing.disabled.upgrade.required", bundle: Bundle.module, value: "Sorry, but Sync & Backup is no longer available in this app version. Please update DuckDuckGo to the latest version to continue.", comment: "Data syncing unavailable warning message") + + // Sync Get Other Devices + static let syncGetOtherDevicesScreenTitle = NSLocalizedString("sync.get.other.devices.screen.title", bundle: Bundle.module, value: "Get DuckDuckGo", comment: "Title of screen with share links for users to download DuckDuckGo on other devices") + static let syncGetOtherDevicesTitle = NSLocalizedString("sync.get.other.devices.card.title", bundle: Bundle.module, value: "Get DuckDuckGo on other devices to sync with this one", comment: "Title of card with share links for users to download DuckDuckGo on other devices") + static let syncGetOtherDevicesMessage = NSLocalizedString("sync.get.other.devices.card.message", bundle: Bundle.module, value: "To download DuckDuckGo on desktop or another mobile device, visit:", comment: "Message before share link for downloading DuckDuckGo on other devices") + static let syncGetOtherDevicesButtonTitle = NSLocalizedString("sync.get.other.devices.card.button.title", bundle: Bundle.module, value: "Share Download Link", comment: "Button title to share link for downloading DuckDuckGo on other devices") + static let syncGetOtherDeviceShareLinkMessage = NSLocalizedString("sync.get.other.devices.share.link.message", value: "Install the DuckDuckGo browser on your devices to start securely syncing your bookmarks and passwords:", comment: "Message included when sharing a url via the system share sheet") + } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/PlatformLinksView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/PlatformLinksView.swift new file mode 100644 index 0000000000..1d7f3b5825 --- /dev/null +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/PlatformLinksView.swift @@ -0,0 +1,206 @@ +// +// PlatformLinksView.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 SwiftUI +import DesignResourcesKit +import DuckUI + +public struct PlatformLinksView: View { + + private struct Constants { + static let goToUrl: String = "duckduckgo.com/app" + static let downloadUrl: String = "https://duckduckgo.com/app?origin=funnel_browser_ios_sync" + } + + private var model: SyncSettingsViewModel + private var source: SyncSettingsViewModel.PlatformLinksPixelSource + + @State private var shareButtonFrame: CGRect = .zero + + public init(model: SyncSettingsViewModel, source: SyncSettingsViewModel.PlatformLinksPixelSource) { + self.model = model + self.source = source + } + + public var body: some View { + ScrollView { + VStack { + content + Spacer() + } + .padding(16) + .frame(maxWidth: .infinity) + } + .background(Rectangle() + .foregroundColor(Color(designSystemColor: .background)) + .ignoresSafeArea()) + + .navigationTitle(UserText.syncGetOtherDevicesScreenTitle) + } + + private var content: some View { + VStack(alignment: .center, spacing: 0) { + + Image("Sync-App-Download-128") + .resizable() + .frame(width: 96, height: 72) + + + Text(UserText.syncGetOtherDevicesTitle) + .daxTitle3() + .foregroundColor(Color(designSystemColor: .textPrimary)) + .padding([.top, .horizontal], 16) + .multilineTextAlignment(.center) + + Group { + Text(UserText.syncGetOtherDevicesMessage) + .daxBodyRegular() + .foregroundColor(Color(designSystemColor: .textPrimary)) + .padding(.top, 8) + .multilineTextAlignment(.center) + + Text(Constants.goToUrl) + .daxBodyBold() + .foregroundColor(Color(designSystemColor: .accent)) + .overlay( + CopyActionOverlay(copyText: Constants.downloadUrl, onCopy: { + model.fireOtherPlatformLinksPixel(for: .copy, source: source) + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + .padding(.top, 2) + + Button { + if let url = URL(string: Constants.downloadUrl) { + model.shareLinkPressed(for: url, with: UserText.syncGetOtherDeviceShareLinkMessage, from: shareButtonFrame) + model.fireOtherPlatformLinksPixel(for: .share, source: source) + } + } label: { + HStack(spacing: 6) { + Image("Share-Apple-24") + Text(UserText.syncGetOtherDevicesButtonTitle) + .daxBodyBold() + } + } + .buttonStyle(PrimaryButtonStyle()) + .padding(.top, 24) + .background( + GeometryReader { geometryProxy in + Color.clear + .preference(key: ShareButtonFramePreferenceKey.self, value: geometryProxy.frame(in: .global)) + } + ) + .onPreferenceChange(ShareButtonFramePreferenceKey.self) { newFrameRect in + shareButtonFrame = newFrameRect + } + } + .padding(.horizontal, 24) + + } + .padding(.vertical, 24) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(designSystemColor: .surface)) + ) + .onAppear { + model.fireOtherPlatformLinksPixel(for: .appear, source: source) + } + } +} + +private struct ShareButtonFramePreferenceKey: PreferenceKey { + static var defaultValue: CGRect = .zero + + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} + +private struct CopyActionOverlay: UIViewRepresentable { + let copyText: String + let onCopy: () -> Void + + class Coordinator: NSObject { + var copyText: String + var onCopy: () -> Void + + init(copyText: String, onCopy: @escaping () -> Void) { + self.copyText = copyText + self.onCopy = onCopy + } + + @objc func showMenu(_ sender: UITapGestureRecognizer) { + guard let view = sender.view else { return } + view.becomeFirstResponder() + + let menuController = UIMenuController.shared + menuController.menuItems = [ + UIMenuItem(title: UserText.copyButton, action: #selector(copyTextAction)) + ] + menuController.showMenu(from: view, rect: view.bounds) + } + + @objc func copyTextAction() { + UIPasteboard.general.string = copyText + + onCopy() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(copyText: copyText, onCopy: onCopy) + } + + func makeUIView(context: Context) -> UIView { + let view = CopyableUIView() + view.backgroundColor = .clear + view.isUserInteractionEnabled = true + + let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.showMenu(_:))) + view.addGestureRecognizer(tapGesture) + + view.copyAction = context.coordinator.copyTextAction + return view + } + + func updateUIView(_ uiView: UIView, context: Context) {} +} + +private class CopyableUIView: UIView { + var copyAction: (() -> Void)? + + override var canBecomeFirstResponder: Bool { + return true + } + + override func copy(_ sender: Any?) { + copyAction?() + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(copy(_:)) { + return true + } + return super.canPerformAction(action, withSender: sender) + } +} + +#Preview { + PlatformLinksView(model: SyncSettingsViewModel(isOnDevEnvironment: { true }, switchToProdEnvironment: {}), source: .activated) +} diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsView.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsView.swift index 6c8b39b94a..d8cd17d5ad 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsView.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsView.swift @@ -72,6 +72,8 @@ public struct SyncSettingsView: View { devices() + otherPlatformsLinks(source: .activated) + options() saveRecoveryPDF() @@ -85,6 +87,8 @@ public struct SyncSettingsView: View { syncWithAnotherDeviceView() otherOptions() + + otherPlatformsLinks(source: .notActivated) } } .navigationTitle(UserText.syncTitle) diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsViewExtension.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsViewExtension.swift index 03d2637a1e..e87a6c86e0 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsViewExtension.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/SyncSettingsViewExtension.swift @@ -105,6 +105,21 @@ extension SyncSettingsView { Text(UserText.otherOptionsSectionHeader) } } + + @ViewBuilder + func otherPlatformsLinks(source: SyncSettingsViewModel.PlatformLinksPixelSource) -> some View { + Section { + NavigationLink(destination: PlatformLinksView(model: model, source: source)) { + HStack(spacing: 6) { + Image("Sync-Downloads-24") + Text(UserText.syncGetOnOtherDevices) + .daxBodyRegular() + } + .foregroundColor(Color(designSystemColor: .textPrimary)) + } + .buttonStyle(.plain) + } + } } // MARK: - Sync Enabled Views