From a3bf844edb60fb0d90a98f6c8bde30aec23d4e4c Mon Sep 17 00:00:00 2001 From: Anh Do <18567+quanganhdo@users.noreply.github.com> Date: Wed, 30 Oct 2024 16:35:05 -0400 Subject: [PATCH 01/29] Update tests to use new tagline (#3439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1205591970852438/1208504563612286/f Tech Design URL: CC: **Description**: Updates new tagline. No user facing strings, but we might as well update the test data. **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) --- DuckDuckGoTests/BookmarksExporterTests.swift | 2 +- DuckDuckGoTests/MockFiles/bookmarks_brave.html | 2 +- DuckDuckGoTests/MockFiles/bookmarks_chrome.html | 2 +- DuckDuckGoTests/MockFiles/bookmarks_ddg_android.html | 2 +- DuckDuckGoTests/MockFiles/bookmarks_ddg_macos.html | 2 +- DuckDuckGoTests/MockFiles/bookmarks_firefox.html | 2 +- DuckDuckGoTests/MockFiles/bookmarks_safari.html | 2 +- FingerprintingUITests/FingerprintUITest.swift | 12 ++++++------ 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/DuckDuckGoTests/BookmarksExporterTests.swift b/DuckDuckGoTests/BookmarksExporterTests.swift index 12d3fe02c2..a0af75f3a1 100644 --- a/DuckDuckGoTests/BookmarksExporterTests.swift +++ b/DuckDuckGoTests/BookmarksExporterTests.swift @@ -247,7 +247,7 @@ class BookmarksExporterTests: XCTestCase { url: "https://www.wsj.com/?mod=wsjheader_logo"), BookmarksExporter.Template.closeFolder(level: level), BookmarksExporter.Template.bookmark(level: level, - title: "DuckDuckGo — Privacy, simplified.", + title: "DuckDuckGo — Your protection, our priority.", url: "https://duckduckgo.com/"), BookmarksExporter.Template.openFolder(level: level, named: "DupeFolderNameContents"), BookmarksExporter.Template.bookmark(level: level + 1, diff --git a/DuckDuckGoTests/MockFiles/bookmarks_brave.html b/DuckDuckGoTests/MockFiles/bookmarks_brave.html index 7ba22d7cdd..d6c224a11b 100644 --- a/DuckDuckGoTests/MockFiles/bookmarks_brave.html +++ b/DuckDuckGoTests/MockFiles/bookmarks_brave.html @@ -47,7 +47,7 @@

Bookmarks

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/DuckDuckGoTests/MockFiles/bookmarks_chrome.html b/DuckDuckGoTests/MockFiles/bookmarks_chrome.html index 08fca4e9b7..54f9bdb6ae 100644 --- a/DuckDuckGoTests/MockFiles/bookmarks_chrome.html +++ b/DuckDuckGoTests/MockFiles/bookmarks_chrome.html @@ -48,7 +48,7 @@

Bookmarks

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/DuckDuckGoTests/MockFiles/bookmarks_ddg_android.html b/DuckDuckGoTests/MockFiles/bookmarks_ddg_android.html index 8a87fa9f41..1e7cb6bf92 100644 --- a/DuckDuckGoTests/MockFiles/bookmarks_ddg_android.html +++ b/DuckDuckGoTests/MockFiles/bookmarks_ddg_android.html @@ -47,7 +47,7 @@

Bookmarks

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/DuckDuckGoTests/MockFiles/bookmarks_ddg_macos.html b/DuckDuckGoTests/MockFiles/bookmarks_ddg_macos.html index 9965f7d234..d3027ccc37 100644 --- a/DuckDuckGoTests/MockFiles/bookmarks_ddg_macos.html +++ b/DuckDuckGoTests/MockFiles/bookmarks_ddg_macos.html @@ -42,7 +42,7 @@

Bookmarks

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/DuckDuckGoTests/MockFiles/bookmarks_firefox.html b/DuckDuckGoTests/MockFiles/bookmarks_firefox.html index 92c60fcd13..24f2804799 100644 --- a/DuckDuckGoTests/MockFiles/bookmarks_firefox.html +++ b/DuckDuckGoTests/MockFiles/bookmarks_firefox.html @@ -55,7 +55,7 @@

Bookmarks Menu

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/DuckDuckGoTests/MockFiles/bookmarks_safari.html b/DuckDuckGoTests/MockFiles/bookmarks_safari.html index 560cbf6533..cf1e87b8be 100644 --- a/DuckDuckGoTests/MockFiles/bookmarks_safari.html +++ b/DuckDuckGoTests/MockFiles/bookmarks_safari.html @@ -53,7 +53,7 @@

Bookmarks

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/FingerprintingUITests/FingerprintUITest.swift b/FingerprintingUITests/FingerprintUITest.swift index bd5c5a869c..e67ce7e248 100644 --- a/FingerprintingUITests/FingerprintUITest.swift +++ b/FingerprintingUITests/FingerprintUITest.swift @@ -88,8 +88,8 @@ class FingerprintUITest: XCTestCase { } let tablesQuery = app.tables - _ = tablesQuery.staticTexts["DuckDuckGo — Privacy, simplified."].waitForExistence(timeout: 25) - tablesQuery.staticTexts["DuckDuckGo — Privacy, simplified."].swipeLeft() + _ = tablesQuery.staticTexts["DuckDuckGo — Your protection, our priority."].waitForExistence(timeout: 25) + tablesQuery.staticTexts["DuckDuckGo — Your protection, our priority."].swipeLeft() tablesQuery.buttons["Delete"].tap() app.navigationBars["Bookmarks"].buttons["Done"].tap() } @@ -110,8 +110,8 @@ class FingerprintUITest: XCTestCase { let bookmarksToolbarButtons = app.toolbars.buttons _ = bookmarksToolbarButtons["Edit"].waitForExistence(timeout: 25) bookmarksToolbarButtons["Edit"].tap() - if app.tables.staticTexts["DuckDuckGo — Privacy, simplified."].waitForExistence(timeout: 25) { - app.staticTexts["DuckDuckGo — Privacy, simplified."].tap() + if app.tables.staticTexts["DuckDuckGo — Your protection, our priority."].waitForExistence(timeout: 25) { + app.staticTexts["DuckDuckGo — Your protection, our priority."].tap() } else { XCTFail("Could not find bookmark") } @@ -146,8 +146,8 @@ class FingerprintUITest: XCTestCase { } else { XCTFail("Bookmarks button missing") } - if app.tables.staticTexts["DuckDuckGo — Privacy, simplified."].waitForExistence(timeout: 25) { - app.staticTexts["DuckDuckGo — Privacy, simplified."].tap() + if app.tables.staticTexts["DuckDuckGo — Your protection, our priority."].waitForExistence(timeout: 25) { + app.staticTexts["DuckDuckGo — Your protection, our priority."].tap() } else { XCTFail("Could not find bookmark") } From 5016b8a0dc6cb8280103e41b34ab88d741a85775 Mon Sep 17 00:00:00 2001 From: Dax Mobile <44842493+daxmobile@users.noreply.github.com> Date: Thu, 31 Oct 2024 21:40:55 +1100 Subject: [PATCH 02/29] Update BSK with autofill 15.1.0 (#3507) Task/Issue URL: https://app.asana.com/0/1208660715919854/1208660715919854 Autofill Release: https://github.com/duckduckgo/duckduckgo-autofill/releases/tag/15.1.0 BSK PR: https://github.com/duckduckgo/BrowserServicesKit/pull/1044 ## Description Updates Autofill to version [15.1.0](https://github.com/duckduckgo/duckduckgo-autofill/releases/tag/15.1.0). --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 81ec1edbe5..26e97cd10a 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10954,7 +10954,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 201.0.0; + version = 201.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 21aa8e0ac9..b048cebb73 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" : "e5946eee6af859690cc1cc5e51daef3c8368981b", - "version" : "201.0.0" + "revision" : "884a5eac964eeeb6d38780a6b90feaf5a5b3cfcf", + "version" : "201.0.1" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/duckduckgo-autofill.git", "state" : { - "revision" : "945ac09a0189dc6736db617867fde193ea984b20", - "version" : "15.0.0" + "revision" : "c992041d16ec10d790e6204dce9abf9966d1363c", + "version" : "15.1.0" } }, { From aa4257c26a1596341dc4fd252934f2aa7800cf19 Mon Sep 17 00:00:00 2001 From: Alessandro Boron Date: Thu, 31 Oct 2024 18:04:55 +0100 Subject: [PATCH 03/29] Onboarding Add to Dock Promo view (#3505) Task/Issue URL: https://app.asana.com/0/1206329551987282/1208577512136709 **Description**: Add the Add to Dock Promo view to the final dialog of the onboarding flow. --- DuckDuckGo.xcodeproj/project.pbxproj | 28 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Contents.json | 12 + .../add-to-dock-gradient.png | Bin 0 -> 15176 bytes DuckDuckGo/LottieView.swift | 24 +- .../AddToDock/AddToDockPromoView.swift | 50 + .../AddToDock/AddToDockPromoViewModel.swift | 66 + .../{ => Resources}/add-to-dock-demo.mp4 | Bin .../Resources/add-to-dock-promo.json | 12131 ++++++++++++++++ .../AppIconPickerViewModel.swift | 5 +- .../ContextualOnboardingDialogs.swift | 123 +- .../NewTabDaxDialogFactory.swift | 21 +- .../ContextualDaxDialogsFactory.swift | 18 +- .../AddToDockPromoViewModelTests.swift | 97 + .../ContextualDaxDialogsFactoryTests.swift | 4 +- ...alOnboardingNewTabDialogFactoryTests.swift | 4 +- 16 files changed, 12528 insertions(+), 59 deletions(-) create mode 100644 DuckDuckGo/DaxOnboarding.xcassets/add-to-dock-gradient.imageset/Contents.json create mode 100644 DuckDuckGo/DaxOnboarding.xcassets/add-to-dock-gradient.imageset/add-to-dock-gradient.png create mode 100644 DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockPromoView.swift create mode 100644 DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockPromoViewModel.swift rename DuckDuckGo/OnboardingExperiment/AddToDock/{ => Resources}/add-to-dock-demo.mp4 (100%) create mode 100644 DuckDuckGo/OnboardingExperiment/AddToDock/Resources/add-to-dock-promo.json create mode 100644 DuckDuckGoTests/AddToDockPromoViewModelTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 26e97cd10a..790620a494 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -705,6 +705,7 @@ 9F1061652C9C013F008DD5A0 /* DefaultVariantManager+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1061642C9C013F008DD5A0 /* DefaultVariantManager+Onboarding.swift */; }; 9F1623092C9D14F10093C4FC /* DefaultVariantManagerOnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1623082C9D14F10093C4FC /* DefaultVariantManagerOnboardingTests.swift */; }; 9F16230B2CA0F0190093C4FC /* DebouncerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F16230A2CA0F0190093C4FC /* DebouncerTests.swift */; }; + 9F1798572CD2443F0073018B /* AddToDockPromoViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1798562CD2443F0073018B /* AddToDockPromoViewModelTests.swift */; }; 9F23B8012C2BC94400950875 /* OnboardingBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8002C2BC94400950875 /* OnboardingBackground.swift */; }; 9F23B8032C2BCD0000950875 /* DaxDialogStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8022C2BCD0000950875 /* DaxDialogStyles.swift */; }; 9F23B8062C2BE22700950875 /* OnboardingIntroViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8052C2BE22700950875 /* OnboardingIntroViewModelTests.swift */; }; @@ -725,6 +726,7 @@ 9F69331D2C5A191400CD6A5D /* MockTutorialSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F69331C2C5A191400CD6A5D /* MockTutorialSettings.swift */; }; 9F69331F2C5B1D0C00CD6A5D /* OnFirstAppearViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F69331E2C5B1D0C00CD6A5D /* OnFirstAppearViewModifier.swift */; }; 9F6933212C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6933202C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift */; }; + 9F72FE272CD223A000BA35F5 /* add-to-dock-promo.json in Resources */ = {isa = PBXBuildFile; fileRef = 9F72FE262CD223A000BA35F5 /* add-to-dock-promo.json */; }; 9F7CFF762C86BB8F0012833E /* OnboardingView+AppIconPickerContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7CFF752C86BB8F0012833E /* OnboardingView+AppIconPickerContent.swift */; }; 9F7CFF782C86E3E10012833E /* OnboardingManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7CFF772C86E3E10012833E /* OnboardingManagerTests.swift */; }; 9F7CFF7D2C89B69A0012833E /* AppIconPickerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7CFF7C2C89B69A0012833E /* AppIconPickerViewModelTests.swift */; }; @@ -736,6 +738,8 @@ 9F8E0F2F2CCA6202001EA7C5 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8E0F2E2CCA6202001EA7C5 /* VideoPlayerViewModel.swift */; }; 9F8E0F312CCA6390001EA7C5 /* AddToDockTutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8E0F302CCA6390001EA7C5 /* AddToDockTutorialView.swift */; }; 9F8E0F332CCA642D001EA7C5 /* VideoPlayerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8E0F322CCA642D001EA7C5 /* VideoPlayerViewModelTests.swift */; }; + 9F8E0F382CCFAA8A001EA7C5 /* AddToDockPromoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8E0F372CCFAA8A001EA7C5 /* AddToDockPromoView.swift */; }; + 9F8E0F3D2CCFD072001EA7C5 /* AddToDockPromoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8E0F3C2CCFD071001EA7C5 /* AddToDockPromoViewModel.swift */; }; 9F8FE9492BAE50E50071E372 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 9F8FE9482BAE50E50071E372 /* Lottie */; }; 9F96F73B2C9144D5009E45D5 /* Onboarding in Frameworks */ = {isa = PBXBuildFile; productRef = 9F96F73A2C9144D5009E45D5 /* Onboarding */; }; 9F96F73F2C914C57009E45D5 /* OnboardingGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F96F73E2C914C57009E45D5 /* OnboardingGradient.swift */; }; @@ -2501,6 +2505,7 @@ 9F1061642C9C013F008DD5A0 /* DefaultVariantManager+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DefaultVariantManager+Onboarding.swift"; sourceTree = ""; }; 9F1623082C9D14F10093C4FC /* DefaultVariantManagerOnboardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultVariantManagerOnboardingTests.swift; sourceTree = ""; }; 9F16230A2CA0F0190093C4FC /* DebouncerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebouncerTests.swift; sourceTree = ""; }; + 9F1798562CD2443F0073018B /* AddToDockPromoViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToDockPromoViewModelTests.swift; sourceTree = ""; }; 9F23B8002C2BC94400950875 /* OnboardingBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingBackground.swift; sourceTree = ""; }; 9F23B8022C2BCD0000950875 /* DaxDialogStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaxDialogStyles.swift; sourceTree = ""; }; 9F23B8052C2BE22700950875 /* OnboardingIntroViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingIntroViewModelTests.swift; sourceTree = ""; }; @@ -2521,6 +2526,7 @@ 9F69331C2C5A191400CD6A5D /* MockTutorialSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTutorialSettings.swift; sourceTree = ""; }; 9F69331E2C5B1D0C00CD6A5D /* OnFirstAppearViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnFirstAppearViewModifier.swift; sourceTree = ""; }; 9F6933202C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingHostingControllerMock.swift; sourceTree = ""; }; + 9F72FE262CD223A000BA35F5 /* add-to-dock-promo.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "add-to-dock-promo.json"; sourceTree = ""; }; 9F7CFF752C86BB8F0012833E /* OnboardingView+AppIconPickerContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnboardingView+AppIconPickerContent.swift"; sourceTree = ""; }; 9F7CFF772C86E3E10012833E /* OnboardingManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingManagerTests.swift; sourceTree = ""; }; 9F7CFF7C2C89B69A0012833E /* AppIconPickerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPickerViewModelTests.swift; sourceTree = ""; }; @@ -2532,6 +2538,8 @@ 9F8E0F2E2CCA6202001EA7C5 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; 9F8E0F302CCA6390001EA7C5 /* AddToDockTutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToDockTutorialView.swift; sourceTree = ""; }; 9F8E0F322CCA642D001EA7C5 /* VideoPlayerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModelTests.swift; sourceTree = ""; }; + 9F8E0F372CCFAA8A001EA7C5 /* AddToDockPromoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToDockPromoView.swift; sourceTree = ""; }; + 9F8E0F3C2CCFD071001EA7C5 /* AddToDockPromoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToDockPromoViewModel.swift; sourceTree = ""; }; 9F96F73E2C914C57009E45D5 /* OnboardingGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingGradient.swift; sourceTree = ""; }; 9F9A922D2C86A56B001D036D /* OnboardingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingManager.swift; sourceTree = ""; }; 9F9A92302C86AAE9001D036D /* OnboardingDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingDebugView.swift; sourceTree = ""; }; @@ -4770,6 +4778,7 @@ 9FDEC7B92C9006E000C7A692 /* BrowserComparisonModelTests.swift */, 9F1623082C9D14F10093C4FC /* DefaultVariantManagerOnboardingTests.swift */, 9F8E0F322CCA642D001EA7C5 /* VideoPlayerViewModelTests.swift */, + 9F1798562CD2443F0073018B /* AddToDockPromoViewModelTests.swift */, ); name = Onboarding; sourceTree = ""; @@ -4794,9 +4803,11 @@ 9F8E0F282CCA577E001EA7C5 /* AddToDock */ = { isa = PBXGroup; children = ( + 9F8E0F3B2CCFD050001EA7C5 /* Resources */, 9F8E0F2B2CCA617C001EA7C5 /* VideoPlayer */, - 9F8E0F292CCA5C9D001EA7C5 /* add-to-dock-demo.mp4 */, 9F8E0F302CCA6390001EA7C5 /* AddToDockTutorialView.swift */, + 9F8E0F372CCFAA8A001EA7C5 /* AddToDockPromoView.swift */, + 9F8E0F3C2CCFD071001EA7C5 /* AddToDockPromoViewModel.swift */, ); path = AddToDock; sourceTree = ""; @@ -4810,6 +4821,15 @@ path = VideoPlayer; sourceTree = ""; }; + 9F8E0F3B2CCFD050001EA7C5 /* Resources */ = { + isa = PBXGroup; + children = ( + 9F72FE262CD223A000BA35F5 /* add-to-dock-promo.json */, + 9F8E0F292CCA5C9D001EA7C5 /* add-to-dock-demo.mp4 */, + ); + path = Resources; + sourceTree = ""; + }; 9F96F73D2C914C3D009E45D5 /* Background */ = { isa = PBXGroup; children = ( @@ -7061,6 +7081,7 @@ AA4D6ABF23DE4D15007E8790 /* AppIconYellow40x40@3x.png in Resources */, AA4D6A8F23DE49A5007E8790 /* AppIconBlack29x29@3x.png in Resources */, AA4D6AA523DE4CC4007E8790 /* AppIconBlue29x29@3x.png in Resources */, + 9F72FE272CD223A000BA35F5 /* add-to-dock-promo.json in Resources */, 1EEF124C2850A93F003DDE57 /* Trackers.xcassets in Resources */, AA4D6ACF23DE4D27007E8790 /* AppIconPurple76x76@2x.png in Resources */, 4B37E0502B928CA6009E81CA /* vpn-light-mode.json in Resources */, @@ -7642,6 +7663,7 @@ BD862E0B2B30F9300073E2EE /* VPNFeedbackFormView.swift in Sources */, 850365F323DE087800D0F787 /* UIImageViewExtension.swift in Sources */, 56D060262C359D2E003BAEB5 /* ContextualOnboardingDialogs.swift in Sources */, + 9F8E0F382CCFAA8A001EA7C5 /* AddToDockPromoView.swift in Sources */, 373608922ABB430D00629E7F /* FavoritesDisplayMode+UserDefaults.swift in Sources */, C160544129D6044D00B715A1 /* AutofillInterfaceUsernameTruncator.swift in Sources */, 31C70B5528045E3500FB6AD1 /* SecureVaultReporter.swift in Sources */, @@ -7842,6 +7864,7 @@ 6FD3F80F2C3EF4F000DA5797 /* DeviceOrientationEnvironmentValue.swift in Sources */, 85864FBC24D31EF300E756FF /* SuggestionTrayViewController.swift in Sources */, D64648AF2B5993890033090B /* SubscriptionEmailViewModel.swift in Sources */, + 9F8E0F3D2CCFD072001EA7C5 /* AddToDockPromoViewModel.swift in Sources */, 1EF24235273BB9D200DE3D02 /* IntervalSlider.swift in Sources */, F1D796EE1E7AF2EB0019D451 /* UIViewControllerExtension.swift in Sources */, 1EE411F12857C3640003FE64 /* TrackerAnimationImageProvider.swift in Sources */, @@ -8024,6 +8047,7 @@ C1D21E2F293A599C006E5A05 /* AutofillLoginSessionTests.swift in Sources */, 85D2187924BF6B8B004373D2 /* FaviconSourcesProviderTests.swift in Sources */, 9F69331B2C5A16E200CD6A5D /* OnboardingDaxFavouritesTests.swift in Sources */, + 9F1798572CD2443F0073018B /* AddToDockPromoViewModelTests.swift in Sources */, 6F7FB8E52C66158D00867DA7 /* NewTabPageShortcutsSettingsModelTests.swift in Sources */, 983BD6B52B34760600AAC78E /* MockPrivacyConfiguration.swift in Sources */, 1E8146AD28C8ABF000D1AF63 /* TrackerAnimationLogicTests.swift in Sources */, @@ -10954,7 +10978,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 201.0.1; + version = 202.0.0; }; }; 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 b048cebb73..f92fe51779 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" : "884a5eac964eeeb6d38780a6b90feaf5a5b3cfcf", - "version" : "201.0.1" + "revision" : "de77673bd4fa7b8012c8d1f16cbc73b064539a57", + "version" : "202.0.0" } }, { diff --git a/DuckDuckGo/DaxOnboarding.xcassets/add-to-dock-gradient.imageset/Contents.json b/DuckDuckGo/DaxOnboarding.xcassets/add-to-dock-gradient.imageset/Contents.json new file mode 100644 index 0000000000..24f4eca3a7 --- /dev/null +++ b/DuckDuckGo/DaxOnboarding.xcassets/add-to-dock-gradient.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "add-to-dock-gradient.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/DaxOnboarding.xcassets/add-to-dock-gradient.imageset/add-to-dock-gradient.png b/DuckDuckGo/DaxOnboarding.xcassets/add-to-dock-gradient.imageset/add-to-dock-gradient.png new file mode 100644 index 0000000000000000000000000000000000000000..38ba5f9b49d116b8ec5a14e76de89026f00c64c9 GIT binary patch literal 15176 zcmV-OJGaD%P)G100^0LOvz75Rd7rzNi9-vO-oBnE-3~8L3#$xYI+sL00009a7bBm0000100001 z08b^v)&KwiFi=cXMMrQ<$KLqE-1)uS|I_vV!{q;KKy`mM|Vr^f%a&HuI4`@zxv zpThsz+3}dZ{~8%hH2?qr19VbOQvj$^8O_4y001BWNklm=)TvX|eD&&?^XcQecmJJC=HutjWIVw?TF#fbr@O9mJ_wL=NPaj@gao&9V6`h{ad@h`NN(-Ea>7s)~ zN^u-3C%i8JgcE03Qb%!~XIaiKg33)^HwK!M9`tj#WNxk{^ z_|h{U&k2f0DLp_-ONeRFDQP+lMNUplaqv8-EW(>Vgi(+L8RV3td8u+T3FTBcEme3` z1<`=ede>Gv{%_a#y7v5d5vY&j7aXOuoGkh3IQgmz@d~FDlgJ5bA|(&%Gv410C~f33 zg_b}lDC4}Ql5&%(n#`&orBwkfnf1EZRg}`2H3v@ZdaaDECiUm#l-`fW*KtxvT6EK{ z>cVNPkVH=X`Fu)Bqaa4fg>{X8TSq}2qT*_536S`)0wF_CYY~&JXitE&+c{2cYX#Cz zd|fA~OAGErlqOPj;!Vz;7AmF#5AWr~k6GM-Iz|<5#JPidlh>Ox&Fi`bsWNGV5&h!h7>VEs2`QX>#P@PE71_134+AS;y+zNfW1(vV^jt zgY%SmDeeUSUa2`*yMmf_@bF4pTsYCA+V+e5hp*IA`{6fGnh=y2_<+=daC#U70;i@~ z>`8s@i|<>nt5KT9aPU!zk1R;jgn1y%A*O^5wNf<|A|_D6H(Twb)2#ui5>vWSPM2_c zv6cvFBB@}hCeET8sHqDDI4rptEj)aDeB{TsV{4K;5M=nb5}BePB_)cfi%sg`W>t|E z@T?>{!Jm)?EF*zf_)k&P@A9WBC~0y!P}73lu1ij_q)URDY9^S?e8ruQPSFU>4$6W% zq3D{_#inGH6e1>iIjRlyM}OK`uv){XEXc3`{r~vO@8I;G@%TDUV@;U8>K1Z*!t~AV zQC?6|&^yx;A4jpJsn`=vgR%?7oVw)H#U_=MB_WZM1WVgGLQLd8Zz#Keo}={oI!3f` zmkB1`#8G+!O1{~#ntDpdXQSB?ktt1ysLty$i6|$TR=}%pF3vt@QG1nHQ&3uY%umcw zpk!~dr?<~w8jr74lh00bUv`?E9HdxtIGf(1pqdG-NAKdF4~7+R8bM7NLwK5`9O@DU z#n|LWx@yI;vsNAaE2Y(vl&ZFibFIz zJJrFJR82L#36cV&nid#1yp_HSK;w*}WcLf9Bfi`b3~= z^6f6}mIPC%Y36G03?U`~#LG;~36Sb0is(&1#JXEacyj)OTLX*@c@lW`aV=r7SwWG=__bv(Hga73URi zOWj5Vc-BftwguNJ>P)x$<7z2Qyf@9gn3U7BQ&Sjf8V{(b8Ko5j=RiSEqgXm!CVehL z5J$zC+L?IaK*5?Y02}kw8rGe4W#^2%QhT!3JNfs!`gd1zGO*0&eGZr9+{0iur;RQQ zwBQs{{B*k8AYzhIkZRH4VEEM-PEIR{O-z~igql!h<|iPb?l=f{-41jrBC5ops=K-P zziz>#a#{|869hFK1}8VyvFu<^k_hlIJK^D>%)XnEP|6s>5ivojcG0Qmy=h&T9iXA= zjLL~o$6#lCi zlc?!jw~GNP4I&o`BsC#o;yZV|ZkM9zX$3B_d8%C10gl^_3#V3pph?;GitX?B`-#c* zI!R7*A1*omY7XzpE_4?Mp=v2~tCaA#$uWR4Vy009L8TEO1N~A*|$L|ND}AZ>~vpE0VP35Yq^U;f?AtiO=?fw4~na5-H3cu zc6xIorNNG1-fu9M2ksR5`#RxsCN)J;ck;t14r^r0k=T4D)%W`uxC@vEVp={dTn<0HIglWJsHXUo zIc0KP7jRe^$)FO+Vy@)mOIf*VgTV!`-m?%;)n5gh)|FjPt@X$*lGObSd_EjkCcQmr za_Y8D62jejPWR$;>hNsk1tDS+B4!jrQShoHX;RvEDak2u*9J<8i(V?pC?uef#Q(ZgO5-7=!>SudsW&HRN!A3XuYG0RlyYQAwbjW!92JLlCz(L*e!EOy zX-w{_#n_>kTn);ni%^yI`eNtXzG$CvbIr}Wp>8<-E-0tzaEaL^Olf#-Ne~~eFL|>P zQ%G*6E8*Z#is?5=NvO$bPOD<*a`a;7osO$kc^+p~mFwNea*R zJfoWGi|$Dgw(T#=?!(>sT(_1R%VKVDy`qs|;NxMJoI<^b3Qic*NSC}pTyLPH4OCUK z<7GK-$=UCKsL7?-4IaB78zWqd^YK0(bfAoV>gRi_1I z%1PD)TssB9na6k-2Wl=;pr#-TDWxVYR}B~9HG7@$oSr=HWE7Na4Xi8 zTzD!pxeIa~=|;jwMwT*{2QDz)kq^|=nEasTd>(g?J;|S}lTxQ9eZ#xA5EFlHESnv* zw8f`j*($BQ{FYPT+dIVpKl$n^Ww@Dh*P25)`8|Q$l?O+U&yBpI>N=pFL{4$a=fRq$ zLBh;y19Osg=SUUDVp6A4M3r-Of+3*&o)Y43@!jH2&=e_=kk`?^Q`0|pivz=F=6Y`e zr|IFMH|+DJY{da-cCPG@!XrL#k!8I6#ki7W(UcAj53ebwymXw749Yb8j6X1XLLa;b(&2yV9S4gdd7(uRhK~R5hXO zoH@O|gDG_}d^voO;KAMf4!T{hra;qp9G@{LJ}`&Tm$NT2LB#vI%!FK~G=KB@0EA3w zE<5|Z`%WqCMMiiVx|9_eNel%c6&cG<91F7I-sSDRQj>QliI{^&pOw;dSa)ttD{?KrVZhXM$&W~IAr?Sc|KoD5Ni)`X|&-}y%1HLLqvSI5F$dWYf8LKS(@k!EK!e>)Y;Q{IeAG|;Bd^kV?LLe z+1|&&C;cwvV3X2;k`?BnU(Uq?ImO}0!A>6y&qgAsn!wUzlx7Vx!H9hFQu|z!kwSA` zMh;j5L7h<*y;~q{9jq-9M7eK`ga#(A5BHdRHHFW+hf?PKt>)tYtXxZKD@hjh5^4#x zheQZcF%-E0Bh^I1v}*e5iE2R+^k#C`dp8G4 zyZy(6VnqtW$xpg*v7GD32kc3T1T6?tnkoPEbp>x?F}&s8=c3{imRFv#YD(y(7h9>- zxyL&Wb5oIg<6?(7urnJAHkqBZnBGzBad{3}K8HYoTDv_0(D9BpIU*z{`nfp1J7sLj zFBmAFp?x;mv z#_+oXqZ}r6@C2~r8^Qv}ad1(5IX=1=Z0^Fu3FPr_Hqc3!<%{qWBSj&|vl(^-#Ix@| zDADPnK`;a%>ek_5sHat4C{QoH;;Q&zSnB1x{rr{ft3kHt~~Vv+CM7NyyQJr9!4N zG+i3y1rRm82!C&NnQ{rp30Q=*eDt|WgcwYjn+t_}6^ z!Ljn8O%o4*Y8VVv34oJF*g5kHB5~)f7%0;4K&QDAabl#bfJ6i%9toKy**tLZlXsqy z*#-0>r0CYgzkkT5pTB0CT16@Amy@O6wya=sw`z1xv&>@7uI6+`eG{XE;M>dr)iCG4 z1Es(wOz?2vgncKzGy;*BBndLGHe!;>mu z5MmQ6)H}m-IXa)Y1S`4Geft0=_0)|b(SER;qD9MQDbwEg2>H#dE^i2%Wpx56Vcz!5 zsds`d5l9?(G`!hBn>hA%kZ!Qa%>|1qMlplrwnuO|{`JHsy2%HB5z+U`Dfi9}OC{@_ z?nop8$ZvN{c&loYJ$<>1fZbynn{b`_Zkt=Ao}$MrYEH2{mtnLJA~8h3h7Q1re?crr z)L?;|J%d`-&piRXfdB`WzX*H zAx>}yz{$ZT6etOaltDxvB9T{4(Zf^!owitOkSf=(($9{&3cO!~+` zp2wOGizmwhrDd>AH*ptMqMo8Bh)7lWgVHB{P*Z!u*$vPsZJh3q?7*YBH)jvP2oEzb zdoYWMK>0)#!in*@LTRG2Np4vFyK}5eo(+Ro2bwRlPnf3tHB7B)PF%^2NCUE zp!l#lGe)wHw}eZ4f}K-{n1RHuizkQfMY^F$Bv=0gcHi@vZxf=7qe<q76+Vm6}oid9#3tn8`N|1rXkF}miq83#Z*c69nodRHSyzp!C&WE%V*VApRErpS!QkRU+F*3>qa zYxqo?Q*`cIx&Z(rzvROu<(VMio(`nmfXEGQ@Q^68a~f<3BCo1+XN=i7L^>gAB_Dcb z63!8J9U7JnMlqXWT&G1qT8ztLP38ZvVd6Ft`wxlU>w_0C`lN6i;q(rBa z1YLCkeX7^$br44JMh+85fjOWOHUevi-_xXkx{J?!%--22Od5I7aZ!Z|Q)pt>dkQ*z zH$@O3`Rc`M2J|_CWt_z|`ZsX~%Qp6;TrJgwCCzOyt6SxZn#GG7fE2|0M8^e-cs@Kc zinM9NBF^TA!2-J?Q2+nvEvLOpNRvl348$m)rZ6?RQEZzSLSn2%vtvZI*;;S#QleyS zC=s+*Z*#ip5MQlQ>+@~PvmwCDi{SHO7KS~h3EEA3ifr=AuCEL=3gS0?u&5VFfLznd zg079>6pW)LJ;5uy%}SPs~#5U5dP^V1n)i8iQuIh-V>0w92MtTh3Ux zr#Ek!Tg5J?q)aSKr>Z-3Ck#TVt~yrn7xVHGgo&_WHYtnl0@VYH-U}SeqqyO&0O(7_+J8coP~4T%N%iZ&k{wKZhMxF9mICi^lF0 zZP`df3aH8SM9Xne%~}o(&~n5c z2<1&Z1fV$ZOcr9ypq>F@NWw@MC57UP@k3RYFtj@Vd5{#Pwe0xv93DY-!bJgDlm40m zy46fqKJ_cjU{BW}r)zjDT~nsVSQ$+Y*$7m^uZ$RD=|=;4dNHv^FmKow|3V zYi?bvkjqkCR*z$+gm9}X;t{Z_>t_wds64bbXJ&8(F`{u{OQJ;QLNd(g>qt zFH$Y&q~ZH?PqgVir(7@!JRN*kYQ7KOwQ24}U7MpPx}>0L`&W|}QMT!QeCxQhvc9VE zhPIRpB?KNIs;g7CAb?<=7D#spw$t$HPd##64RE?~==%;l8tK&cHD@qEM+hPc)E~xA zMnX@t>Jvu=C4Z3oo=|A7Mv0X=gcyW9(b4;uy)d-OkRlo}r@D%?3A@Dr zHa&w`eb@C+Dd?2%Mqfj}Df%eZ0%=0#BBn4teI(9-L5cSZhKWz;LjwE&DF3irHW^5X zI3-3glWD0WkSaS5^<4(2X^Q3XUyIj;${Vj{kHwo($WRhSa01v-+;yjCjF*~pY4Q3B zaRNYN%{YxAqI{?59P+Rdz6dG@frr65#ETU@mLH166qXIz~0 zhMAm5Q&T27Ql?)EpjPpve6zgN#1Pd)q|-B4H9SMY8KcoW)2<%P0FeTfdLT8{6n?qp zgtJ3{;&32*u+dJ>4+lL>pn4zj;|-%ko*tIDTyHb~(-yF8uOJI=n9b>=O*=&qCAAUo zTxA_zNw6yO+(KUAL_Dgx>h$LV0`*)2p}%S#i&uo)pJV+DXaJDG*MTqSO++Tk{|Ds| zfiaF5PI8h*p@g6KP_d)_uH}R_1+mG2Ng))@W3x#_ZW$Kk(rXH(&{XtVtYcMfn=jO@ zs_4P97hMsVYSJb+@W1{?+u0;5s%%l1NxV0aigUxzz7wbrCm=Y2;{@y=OaSfOu)|-E z4gt6KdYl^6Tl+6F;drX*HY!zAJXQo>t-W&RCP}wDxcJBal+$+m@$u1jQx6&a_=kiP zxQsjEMs-6Vnnu*rNVDfg6(%=t=ITye_tMNGCm+W}eOOYUy(uJwr+r!wepUk4fl7Gc z%9%&Lm+<;SxBO0+gZGkO_Q)#{Xg!H(+QPwGh6E>{=6N^GAKkN=N8#l9ER&3TBVonV zgk7Wb1R%fCcR#u?MbIvD9eYt&Cepi72`2<-Npc_j(Mf9ovUl%KsC){$*p?(nf@ON$ zNne+ObId_WyB&;)d$`^6IpWdc{(&3-z1=v%mXJOH)pq`vUGJs-+^J}~rFwYBQcaCO z(Ch%W~6qT`ts}e3BYC z-JZKG#~dJS`*u5S|DmXp$Q#EN_B6Ni^QkV=h)s)VW*<;OX31vV0#1z@T*GlUOI=}+ zAVe-`Y!LyOcf&zt`q%5TNrn@XB;i~bz!M@3 zGJ1~U;v*qK1EX<%b}b#=h?gDnk!{n6b+3KsCy+X4Q_Bop)2WbCWbq^gY|WrgsjBHD zInZ9MO44hoP0Cl8S5W+3aUw#nB}xZ^1+Vl}=C`+?h(a;P4P+2>(*#Ci-#+Q~Y*117&f(bswk(Jp*@H^Q2F%D2be1VUi-YzfrVv``R)MQBEb^3il=&A%g{ zuRvMM&Yg~vT?s)r%`WIHvQEM2T6eTIj&)Kqo$XVH?{xaCB%);9+;aIexlGefYw9_* zoZ=)`WxFj@XXP^OaM7)#{&c>NGd+d}98ibAxI%^k}aU zwt#a=)pBf(pbpY(;*yKH)Gfuk|CClLBb`9-9v@?Wd5=t#3u`E1N)!6HD@h95o>cS% z9|G0j_N2||%xC^3n@261N5flt{fbrN640)j!<1E`FOt4I^$Vbi$$UEIeIS>;S(p3J zzdS9SNzOl=lR!;+#c#M$|IVC=UHp2Tp4V~QKvpA#97II6gd5*ylG7JmeaI+~QOM>| zzY07V<*m$|6~(DkWU^R-__C>RLSSDt=m^f8UL+k@&9wMtwM&~@A&92u)L(l@X+o~l zj?Zg5PAyaf<0eu9>Zej-<$KB2R**+MmW5b83i7 z!;pA)ZIY6mFYU@h>NtUNqeV(OqtoZCIsF}23+v6{UZw#gbX- z{IB!En%q^G;|R)k$oq@kJB8#+YcM~nA^!Y-X3C_$iFuesb?C84UFlU zk8L#$ZC;IZ#CVs}6Al~qrmBR59JYiQsR_GCQIGsV_+&%q>Pl#7-}gdKl8X7Fi}ZJY zNKJ{9nM6$&tAxJ?KmV|pVjVWfqo35VuI;dC^EM|?ZBvkfM!G{qIYm*$&sCFEq!7{%!N_wl zp}Ky!R&%6D*7V@#S}clV@bRgX&^beQ#q&^2F_BSTo?9ZlRpNI4X4J*9L?{=UVh>8I z;EPI=wksRbv{5mUl%}?5^CHg+a2g0q&Kuq4xxJ|%qsnMlfLVJ4O+KehuGzs0m(l{C zgdR&O@`YG;w%;a4q)tn5*m?a*VO00#%QDYm;Fkk0uO3s#y$1!}w_u>sPhi9;0mdiO zbS(gBJG2GC8O9crzyqil6zM6?Afr43oT|zy1Y2FX$Xd60-H0b!TPKOz1TtXKr8QG2$_p_9eLCd!+PlH)|#0D3smk!oo|*cI1& z7zc813a+9WAev5~9+EOkjnUZomm9%hWRlo2*a?s25OK%wx9Z-OxupzJ*qOwd;70{V-KId=1U<1e z8JkMjRD92BiZ=wDcK8b_c;H5@ehG@@#FnOZUcXe77D=N-e~l>BizYlCqosQi4L;S6 zt`wu}xRwh@l2%ggowzH+E^a2u@!%IB@K>tzq^1o&dhO<*ry|=7P*F}&VoPEx!V^)- z_)ehK8oJBlt}l2e-q0@IcVCf0&nT2AOAWO&Tg9Bmdr|79ABh0uBEi(^ANo$vCHhV~ zKG~`9NOaP~K90}H*-Dm)W8{k9{)~9fkSH6NrrmU%NKDklZJH?`5p^y35i4k*Cx*kE z@cKcf;o^Y zL@(jB&^!mBNpWU#E6$V&oH+ykemK6pnK${|| zRlL=9qnOJ|sOT=9O>79yqa;VV-Lm^l)C7uPL>xUVl~h$rLY`CFS~V*{by~3NB4;9E zBw_pNgGBrNBz5@I=qa02O~!WCDa;0Rx!K^d)n00G&=LkI(=@P}sE->!3H=zL6ig;( zQfx}}fQ)j8h#B3ol5~T11Cv`VPDF3-*62N0-NdCR^nA-H{%Gi&KdObGW`Ck8vj2De z=>eG{Y1ehFxH+pbHqPJ^gGNf?E{=VlP*Pu5ypf56 z6*go^mD3Ql5=tqtxz%)sc<{2kgBYQ>E3%rfXxNn;hWbY$CgRC@;ycrk-1U`1HPK%_ z%nokV7fG9Kj~;UH-ZRrK%{xBVz5SO0DZTp=il4B<#~v0VBS1Vw3u3=>~DRP)~rb4guyisC|WWg$3^E0uoEJz!OQKF~i zB;GFE&m9TTdvL8`FizQUFGo@mN)yNw#%C?0Qf}H9QE5K(HKK>J5 z(nunZR4`6t8b7MIgIbRTsa$<2N>i|lm^*r7a##22nO<~pG**F{CIsBr5+8vx6l)=g zh$!F;js)AHU;8O5?hwm+MyXbIyP2HWpLoOwo8t0l5P4PfUqxa)x4f3EYqAnd*#BuD z%8x-Ov9~k*?h%{!@Mp5#f~$GL**fIj{gu)H8p&P5UTzU=OeY97#&TnC`aM7%nAo@fPumsjHg04= z%>+5^B|xxy4iMxE2x1NMGYCx%J{$Q0-;?-4Sun7B6SE+Y&0I8-qhP==vHxLWLGVii zrC(LO?k43)GTAI`Q<6PnK;?SX^%Z4o##^g25Q_tcz#5OpNcDBLk)wSrYA80) z^(Ie9=a|ej&L%kk6K|+?cxwPjQFnd?YU)*IAe}-qw|mJI(0asJI=(ruUM~ywKTu9&27MjvQ3bv z+M5sYrv);AONfC~1n`Vf8~xgiCC`n0hRAE+41ChY^~qqm{wRQyroD+O6^FFzH1ucI z+ZU*?2PNN}0plEM1DZ++I9a$c*WEfw3^xpD2GZ^vpA=yOkQwkaFsS1X#ip(ua*$e% zmp|Z-@D3P>OpRn5dXDL??~F}<_H@Gd?inH8R&Fty>Sqxi@Tq< z`x%=y?j$v)y$BHi7#u9cT=*_d%2`!KiuYaa+Q2Hie0Gy$HA<-xW;LZNn*oXf0)y+$ceHXIia@ zVyqD?`}Q!5UDEQX=&iG9n5DukGsFh=si8F8H7Vl2rv0DDrt2kO80La{*Ic!l?_i+@ z=S!3aE<)Hznzkwnxb60=WQLF^vxq3WMgxgY?CA)|?0XcfF3Nly@I0i=K{X6HtJ-L^ zi){LS_357%Y1di2 zxus>=y8D)Tr@TIdSSw_@C^`{@&Z#pFqmgMG@X>WnF_*85O}iL&jZh+??l6bLxp#!# zkE>6z#oVxf0;tY6#`$;&uWtmA_8(#!cvb+%6lRagZb*2o`hB<0GhI(|6Pbn<9;l1s znTP`d*)2RJIC$-W*O6y5=tPG$yKrSW2MBkmb1S&{qG7;($N~6(e#AD7V z(MsW&y8;t&XihI?=Fx?C@7kdU3U$xu4s*u#y7 z_LXU?y|k-rMK)PO)2&U;Jy!jrvN<2*JkQ<5c}3;=FumjkvELQx0#)8b z1!5R9oA|Rrdt)66DeCU-zF57^|L($L&E1vAqq22{#rZdQd3;oerUFq{(x;zf?euW0hbkc zWOb?WG%o2<1c~F93()Vm22so1K?`r1iFhk>>D*sQJn&vR;$%RS*O$Gc~Z>m zT9>lW4rMGvDz*fN3sGE>qB#^|RL{!aA(ulpQ{{be&L(BMD4ArHBa;-W(a2b%ZYTNq zt&33@Jr&uRd;wy7V>8H_Fy$OG*>t!6cYx`yI;e5}_b+qIeB+nMIZ@7;6~inCC_8es zTY4`j*MB}Xyt?PatFy}~hA0c^gM?T0$8^VKYZ+Az`^-s|Ht>v;B6lR}2!~!`uF_wLTrN=_%#B zo6UWL1mb=Jh}xLT4j%yMcr;j!W4PhvNRTExUoZ+dfl|4Mx4Sgh#hhhN=M+F{-rI3B zuTxeUccQH8Lpk&pT3 z!A8~`N1!CQ$g63IlH66Q6%1#U0=MGO@jH!o?;9LY=S?C$EOC1m1q@qAio`2Uj4VVt z@`woIh=Sz?gj~U6>*e}O^vXk-dY_fkC>L0?dye7o>>l8ZNTdwmIga=Wua3ZH;`hO{ zX%Tq#%OKXE@zcvrW=vfx>KYM{VbYK3o> zUd{)&Dz>9Ov0$mf0LubfGXB3N5lT=b-J^U`7HAA8q7rTsW6xeF{=)ruJo-P5PLpi% zn)#AVQt=&4L97$}6EJ;}s`+(MM4Sp|lbb_7qe1bfI-Bo6EDAbEa!TbaK`Cr!CUf7_ zlN{59BF&_DheEzC z1&O-YY9RyYy#7HwDq;^nok0X-5_ad8#hG>);7V0ya;P$4@{-naX9Y``0J~tlqj*O} z!Ua%H_9VRw*D+m_L|no(W%kM4Q`;t>WC>6x&_IWlSl9xcfBbnq9A&3jCN`DXS)io= zLs9}a8;=5#y}8Yfk{ z7?n#ES4m|^69{NGY$f3vc3+?-Zn5`H=lXtGR9E3z9`#u+p8IifdhVwe>&Kq9Eq=>^ z(yc25-NoerT@}KJr8uvim?#-Y&rDC21Kg4C{ABQ)atcj`)G6G@Q%0$Y|ABZ-c?oLp zd8w>U5X*DPnxO8q|6VY?Dj)LeoDcQlu}JH@Jgt41NZi)?P^rffTj|);CFck%Q%S|| z&417;00ECPM>$YO4){PyI%LpAyeR6fmvtP745hX#j-?N4IAAU4@ zQC#T?@3^{}Q>&a~R4xej0eoVkE>;zW%mk}1pD?~vpi+Ok_3ldLavKbCcC)&`qJ)I- znPj?5oco7S{n_<jPL=l_}rk%L~2YJ z9kW`2YIIrKMRMY3%k%8FX})umR-YOuWKnpqkE`PF5Z>BV@kg%8w>&9q(LC$9SUUf< ze|m~;>B4(;5ppKo_nYTu36pH)%q=mRa6^=xB6BR(0z?pV;&r4(P2e9G&Y9q0J+ z?u(e#DP6&|rm^YU2-B;wzX}`*H$7feRc#oBuLr2gmG2Foc8UKWi5P2`{>F=*)g?<& zZf_4J-XK(7iBk!KVR!HHJ>))sYBT`>05nmOaOEhSJo@lO`eAsS=1B|7b6r;Hl@X6q z$=P#auo<<45}q8TS5U5FG@V&;H)ygYug%e8ZYWOf2*9RI zLTU9SAgaiw-cWkL>x!RORUp+NEivIOj$s8Gl`?n4Q7zD@LIP)>Z*BR`1x7bGCIIDZ z+M!*bp?-IZ3MCV^BPqq0I-QO&x&W6DIuSp|DN`w;?IYcgEI}6syVy_=*D)BIzRzHK zg&J9SXAf0n0QI8Q!3cE#SVW+jNsmglRci4r97NNk3cvNzbm?}>dVrxMUBX(Ot%qDN zl{<3DEEBzVr^G}cofL2>*3vb_$0KDc9}7L%n|T6F)(u&y7hS6jKVtpzvkazH-%FbU zKs_4$dRu-AmAW!~jA80f7E0unHax3zl*}PcFNOcCptHtGZ;9JJUdUMPGy5HA6mlw= zf%|#7dsKdMa>4QFnocJL-#K2mPcxr6<^?E@b?q;$TrqCkNDw5HI?*b!p~O~nfIlFZ zPtj*ADgowRnW>t1Qk8m%H`$zvq7U&!T}#USr1~`eD=Dft#A-bA!tcLoX$CYaS=H5*UELuh zlv&87w<`fr&9w4d*iv=v8{RSHZX2E~wj52ZpK}vR$`cu+VR(s*Ih=wBBO#oYX`12$ z84H#=okOWGm#2mkd=Fqcs_SQpBSceu!GMieNxIegqJqQ=y=UTUR19K-|Ey58A>`ii z`oIGvTSfL9eu4p-uG5(4FT~f(NCd`ET3Y2>6iJOH$*65W8N`GX44p_T{PR^@Jcp8Y zdEr(U5<{EpJxAws1m_Q9#Ib~PfeqYP-d18qlGNx`?236EtKL>iJ3`S^uO7$?M`5%g z^$jwdZ{&P1jddxdcDF#w8xASuw&MH0pM{S`708qZlZu@xrXZTkv`z>CFMPmcO*s7s zQ5uVQ{aiCt5>pq5tD2LMQ;#QWv7oWiI=ylPQlF}#OKo*WC{ZAZmdi$gb6juW0)8mR z%L6KsY z`41dqE5J441yoSAIDitw~sG0b#|QuLwr=eeF9$wm?%`2%mMHvRZskq@3afGWHb(^*r}MC$ma3+;yidJI zkQc_9qB*xAm}Z6(;4w(aT}aS~dTS2%x47TD1J!#f*r8S|<&0dwin>yjdL+)UTR5Sn zIZ-9-eb;L4o%rT7?KTC|%W&GBeexA60E5H|_bQ|NUw+lOdsTp676{yxBTB@+n;cNV z!n7{K>3|AKyB$_rIvpXH(ScCR^>jVM%~z7wf(e)1?uVC}Dc3UQ+wpj}>8|BN=4ZQ= z(N0oQ2B4i3LYa)be^*nWp)UbkK{iomPOOjFC55;t~T_0#1bGD;nNG^rPOe& zlC^=60L5wi{S&=Y5P&-%$G}9h#kf5>2{zP!`h>IiYm<7D~}kHXop0^(`Tk@eWhQ(UkqZ z7qujjFDE{v^x9|=oNhix2gK=?k;0-E zRi=v7lKNg32+gxXM)!syzzP!v;p#$lB9o3T50r6;D8y11TI#VYnZGLnFZ1lGLI`%!j5;qqktbM=d6h? z;7w~|ws7ORljt4iXfX#OspAY5R9tVS^d{zN`k+_7l-C7Vvuw%Myl&kzMI4j|qXUBu z%aP8VovoC{=fsP3i_g*f&e50CHw*Zq1k{Mst;5Ji6PS~haJRuLuMAN%rEJp)3OK80 z-OiHbrs~wW5tbnYt#e?yb93@ukZ{h<7K_EFPan5da!I|cHnr*A+;U+jm!yx_c5X=j zMmx6*oD~NVP$noTAu#lEz1A)1@qhnYEKbh0A@M)-kXCfPhfbvc0000 private let loopMode: LoopMode + private let animationImageProvider: AnimationImageProvider? + private let valueProvider: ValueProvider? let animationName: String let animation: LottieAnimation? let animationView = LottieAnimationView() - init(lottieFile: String, delay: TimeInterval = 0, loopMode: LoopMode = .mode(.playOnce), isAnimating: Binding = .constant(true)) { + init( + lottieFile: String, + delay: TimeInterval = 0, + loopMode: LoopMode = .mode(.playOnce), + isAnimating: Binding = .constant(true), + animationImageProvider: AnimationImageProvider? = nil, + valueProvider: ValueProvider? = nil + ) { self.animationName = lottieFile self.animation = LottieAnimation.named(lottieFile) self.delay = delay self.isAnimating = isAnimating self.loopMode = loopMode + self.animationImageProvider = animationImageProvider + self.valueProvider = valueProvider } func makeUIView(context: Context) -> some LottieAnimationView { animationView.animation = animation animationView.contentMode = .scaleAspectFit animationView.clipsToBounds = false + if let animationImageProvider { + animationView.imageProvider = animationImageProvider + } + if let valueProvider { + animationView.setValueProvider(valueProvider.provider, keypath: valueProvider.keypath) + } switch loopMode { case .mode(let lottieLoopMode): animationView.loopMode = lottieLoopMode diff --git a/DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockPromoView.swift b/DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockPromoView.swift new file mode 100644 index 0000000000..3d69df3eba --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockPromoView.swift @@ -0,0 +1,50 @@ +// +// AddToDockPromoView.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 Lottie + +struct AddToDockPromoView: View { + private static let appIconFillKeyPath = "**.Backdrop.Fill 1.Color" + + private var model = AddToDockPromoViewModel() + + @State private var isAnimating = false + + var body: some View { + LottieView( + lottieFile: "add-to-dock-promo", + isAnimating: $isAnimating, + animationImageProvider: model, + valueProvider: .init( + provider: ColorValueProvider(model.color), + keypath: AnimationKeypath(keypath: Self.appIconFillKeyPath) + ) + ) + .onFirstAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isAnimating = true + } + } + } +} + +#Preview { + AddToDockPromoView() +} diff --git a/DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockPromoViewModel.swift b/DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockPromoViewModel.swift new file mode 100644 index 0000000000..1dd2f5c941 --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockPromoViewModel.swift @@ -0,0 +1,66 @@ +// +// AddToDockPromoViewModel.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 +import Lottie + +final class AddToDockPromoViewModel { + private static let imageName = "img_0.png" + + private let appIconManager: AppIconProviding + + var color: LottieColor { + LottieColor(icon: appIconManager.appIcon) + } + + init(appIconManager: AppIconProviding = AppIconManager.shared) { + self.appIconManager = appIconManager + } +} + +extension AddToDockPromoViewModel: AnimationImageProvider { + + func imageForAsset(asset: Lottie.ImageAsset) -> CGImage? { + asset.name == Self.imageName ? UIImage(resource: .addToDockGradient).cgImage : nil + } + +} + +// MARK: - Helpers + +private extension LottieColor { + + init(icon: AppIcon) { + switch icon { + case .red: + self = LottieColor(r: 0.87, g: 0.34, b: 0.2, a: 1.0) + case .yellow: + self = LottieColor(r: 0.89, g: 0.64, b: 0.07, a: 1.0) + case .green: + self = LottieColor(r: 0.22, g: 0.62, b: 0.16, a: 1.0) + case .blue: + self = LottieColor(r: 0.22, g: 0.41, b: 0.94, a: 1.0) + case .purple: + self = LottieColor(r: 0.42, g: 0.31, b: 0.73, a: 1.0) + case .black: + self = LottieColor(r: 0, g: 0, b: 0, a: 1.0) + } + } + +} diff --git a/DuckDuckGo/OnboardingExperiment/AddToDock/add-to-dock-demo.mp4 b/DuckDuckGo/OnboardingExperiment/AddToDock/Resources/add-to-dock-demo.mp4 similarity index 100% rename from DuckDuckGo/OnboardingExperiment/AddToDock/add-to-dock-demo.mp4 rename to DuckDuckGo/OnboardingExperiment/AddToDock/Resources/add-to-dock-demo.mp4 diff --git a/DuckDuckGo/OnboardingExperiment/AddToDock/Resources/add-to-dock-promo.json b/DuckDuckGo/OnboardingExperiment/AddToDock/Resources/add-to-dock-promo.json new file mode 100644 index 0000000000..374632bffd --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/AddToDock/Resources/add-to-dock-promo.json @@ -0,0 +1,12131 @@ +{ + "v": "5.9.0", + "fr": 60, + "ip": 0, + "op": 120, + "w": 155, + "h": 52, + "nm": "Add-to-Dock Promo", + "ddd": 0, + "assets": + [ + { + "id": "image_0", + "w": 588, + "h": 176, + "u": "images/", + "p": "img_0.png", + "e": 0 + }, + { + "id": "comp_0", + "nm": "Pre-comp 1", + "fr": 60, + "layers": + [ + { + "ddd": 0, + "ind": 1, + "ty": 0, + "nm": "Stars - Bottom Right", + "refId": "comp_1", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 78, + 78, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 16, + 16, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + -100, + -100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 32, + "h": 32, + "ip": 20, + "op": 475, + "st": 20, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 0, + "nm": "Stars - Top Left", + "refId": "comp_1", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 26, + 26, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 16, + 16, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 32, + "h": 32, + "ip": 20, + "op": 475, + "st": 20, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 0, + "nm": "DuckDuckGo Icon", + "refId": "comp_2", + "sr": 1, + "ks": + { + "o": + { + "a": 1, + "k": + [ + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 20, + "s": + [ + 0 + ] + }, + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 35, + "s": + [ + 100 + ] + }, + { + "t": 42, + "s": + [ + 100 + ] + } + ], + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 52, + 52, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 28, + 28, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 1, + "k": + [ + { + "i": + { + "x": + [ + 0.833, + 0.833, + 0.833 + ], + "y": + [ + 0.833, + 0.833, + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167, + 0.167, + 0.167 + ], + "y": + [ + 0.167, + 0.167, + 0.167 + ] + }, + "t": 20, + "s": + [ + 10, + 10, + 100 + ] + }, + { + "i": + { + "x": + [ + 0.833, + 0.833, + 0.833 + ], + "y": + [ + 0.833, + 0.833, + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167, + 0.167, + 0.167 + ], + "y": + [ + 0.167, + 0.167, + 0.167 + ] + }, + "t": 35, + "s": + [ + 120, + 120, + 100 + ] + }, + { + "i": + { + "x": + [ + 0.833, + 0.833, + 0.833 + ], + "y": + [ + 0.833, + 0.833, + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167, + 0.167, + 0.167 + ], + "y": + [ + 0.167, + 0.167, + 0.167 + ] + }, + "t": 42, + "s": + [ + 80, + 80, + 100 + ] + }, + { + "i": + { + "x": + [ + 0.833, + 0.833, + 0.833 + ], + "y": + [ + 0.833, + 0.833, + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167, + 0.167, + 0.167 + ], + "y": + [ + 0.167, + 0.167, + 0.167 + ] + }, + "t": 47, + "s": + [ + 106, + 106, + 100 + ] + }, + { + "i": + { + "x": + [ + 0.833, + 0.833, + 0.833 + ], + "y": + [ + 0.833, + 0.833, + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167, + 0.167, + 0.167 + ], + "y": + [ + 0.167, + 0.167, + 0.167 + ] + }, + "t": 52, + "s": + [ + 94, + 94, + 100 + ] + }, + { + "i": + { + "x": + [ + 0.833, + 0.833, + 0.833 + ], + "y": + [ + 0.833, + 0.833, + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167, + 0.167, + 0.167 + ], + "y": + [ + 0.167, + 0.167, + 0.167 + ] + }, + "t": 58, + "s": + [ + 102, + 102, + 100 + ] + }, + { + "t": 65, + "s": + [ + 100, + 100, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 56, + "h": 56, + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 0, + "nm": "Mail Icon", + "refId": "comp_3", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 1, + "k": + [ + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 20, + "s": + [ + 155, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 30, + "s": + [ + 196, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 35, + "s": + [ + 182, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 39, + "s": + [ + 191, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 45, + "s": + [ + 191, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 50, + "s": + [ + 188, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "t": 60, + "s": + [ + 191, + 52, + 0 + ] + } + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 28, + 28, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 56, + "h": 56, + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 0, + "nm": "Music Icon", + "refId": "comp_4", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 1, + "k": + [ + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 20, + "s": + [ + 225, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 30, + "s": + [ + 266, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 35, + "s": + [ + 252, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 39, + "s": + [ + 261, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 45, + "s": + [ + 261, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 50, + "s": + [ + 258, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "t": 60, + "s": + [ + 261, + 52, + 0 + ] + } + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 28, + 28, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 56, + "h": 56, + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 6, + "ty": 0, + "nm": "Phone Icon", + "refId": "comp_5", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 1, + "k": + [ + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 20, + "s": + [ + 85, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 30, + "s": + [ + 126, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 35, + "s": + [ + 112, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 39, + "s": + [ + 121, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 45, + "s": + [ + 121, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 50, + "s": + [ + 118, + 52, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "t": 60, + "s": + [ + 121, + 52, + 0 + ] + } + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 28, + 28, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 56, + "h": 56, + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 7, + "ty": 0, + "nm": "Background-Vector", + "refId": "comp_6", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 155, + 52, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 155, + 52, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 310, + "h": 104, + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_1", + "nm": "Stars 01", + "fr": 60, + "layers": + [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Star", + "sr": 1, + "ks": + { + "o": + { + "a": 1, + "k": + [ + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 0, + "s": + [ + 0 + ] + }, + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 16, + "s": + [ + 80 + ] + }, + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 17, + "s": + [ + 100 + ] + }, + { + "t": 29, + "s": + [ + 0 + ] + } + ], + "ix": 11 + }, + "r": + { + "a": 1, + "k": + [ + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 0, + "s": + [ + 45 + ] + }, + { + "t": 29, + "s": + [ + -124.047 + ] + } + ], + "ix": 10 + }, + "p": + { + "a": 1, + "k": + [ + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 0, + "s": + [ + 26, + 25.083, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "t": 29, + "s": + [ + 5.408, + 5.61, + 0 + ] + } + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 1, + "k": + [ + { + "i": + { + "x": + [ + 0.833, + 0.833, + 0.833 + ], + "y": + [ + 0.833, + 0.833, + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167, + 0.167, + 0.167 + ], + "y": + [ + 0.167, + 0.167, + 0.167 + ] + }, + "t": 0, + "s": + [ + -63.663, + 63.663, + 100 + ] + }, + { + "t": 29, + "s": + [ + -100, + 100, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + -0.429, + -0.086 + ], + [ + 0, + 0 + ], + [ + 0, + -0.6 + ], + [ + 0.6, + -0.171 + ], + [ + 0, + 0 + ], + [ + 0.086, + -0.429 + ], + [ + 0, + 0 + ], + [ + 0.6, + 0 + ], + [ + 0.171, + 0.6 + ], + [ + 0, + 0 + ], + [ + 0.429, + 0.086 + ], + [ + 0, + 0 + ], + [ + 0, + 0.6 + ], + [ + -0.6, + 0.171 + ], + [ + 0, + 0 + ], + [ + -0.086, + 0.429 + ], + [ + 0, + 0 + ], + [ + -0.6, + 0 + ], + [ + -0.171, + -0.6 + ], + [ + 0, + 0 + ] + ], + "o": + [ + [ + 0, + 0 + ], + [ + 0.514, + 0.086 + ], + [ + 0, + 0.514 + ], + [ + 0, + 0 + ], + [ + -0.429, + 0.086 + ], + [ + 0, + 0 + ], + [ + -0.086, + 0.514 + ], + [ + -0.514, + 0 + ], + [ + 0, + 0 + ], + [ + -0.086, + -0.429 + ], + [ + 0, + 0 + ], + [ + -0.514, + -0.086 + ], + [ + 0, + -0.514 + ], + [ + 0, + 0 + ], + [ + 0.429, + -0.086 + ], + [ + 0, + 0 + ], + [ + 0.086, + -0.514 + ], + [ + 0.514, + 0 + ], + [ + 0, + 0 + ], + [ + 0.086, + 0.429 + ] + ], + "v": + [ + [ + 2.743, + -1.8 + ], + [ + 5.057, + -1.2 + ], + [ + 6, + 0 + ], + [ + 5.057, + 1.2 + ], + [ + 2.743, + 1.8 + ], + [ + 1.8, + 2.743 + ], + [ + 1.2, + 5.057 + ], + [ + 0, + 6 + ], + [ + -1.2, + 5.057 + ], + [ + -1.8, + 2.743 + ], + [ + -2.743, + 1.8 + ], + [ + -5.057, + 1.2 + ], + [ + -6, + 0 + ], + [ + -5.057, + -1.2 + ], + [ + -2.743, + -1.8 + ], + [ + -1.8, + -2.743 + ], + [ + -1.2, + -5.057 + ], + [ + 0, + -6 + ], + [ + 1.2, + -5.057 + ], + [ + 1.8, + -2.743 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 0.22265625, + 0.411768317223, + 0.9375, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Star", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Dot", + "sr": 1, + "ks": + { + "o": + { + "a": 1, + "k": + [ + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 0, + "s": + [ + 0 + ] + }, + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 6, + "s": + [ + 80 + ] + }, + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 16, + "s": + [ + 80 + ] + }, + { + "t": 29, + "s": + [ + 0 + ] + } + ], + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 1, + "k": + [ + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 0, + "s": + [ + 26.472, + 20.361, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "t": 29, + "s": + [ + 21.062, + 2.75, + 0 + ] + } + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 1, + "k": + [ + { + "i": + { + "x": + [ + 0.833, + 0.833, + 0.833 + ], + "y": + [ + 0.833, + 0.833, + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167, + 0.167, + 0.167 + ], + "y": + [ + 0.167, + 0.167, + 0.167 + ] + }, + "t": 0, + "s": + [ + 68.889, + 68.889, + 100 + ] + }, + { + "t": 29, + "s": + [ + 100, + 100, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0, + -1.375 + ], + [ + -1.375, + 0 + ], + [ + 0, + 1.375 + ], + [ + 1.375, + 0 + ] + ], + "o": + [ + [ + 0, + 1.375 + ], + [ + 1.375, + 0 + ], + [ + 0, + -1.375 + ], + [ + -1.375, + 0 + ] + ], + "v": + [ + [ + -2.5, + 0 + ], + [ + 0, + 2.5 + ], + [ + 2.5, + 0 + ], + [ + 0, + -2.5 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 0.22265625, + 0.411768317223, + 0.9375, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Dot", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "Dot", + "sr": 1, + "ks": + { + "o": + { + "a": 1, + "k": + [ + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 0, + "s": + [ + 0 + ] + }, + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 6, + "s": + [ + 64 + ] + }, + { + "i": + { + "x": + [ + 0.833 + ], + "y": + [ + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167 + ], + "y": + [ + 0.167 + ] + }, + "t": 16, + "s": + [ + 64 + ] + }, + { + "t": 29, + "s": + [ + 0 + ] + } + ], + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 1, + "k": + [ + { + "i": + { + "x": 0.833, + "y": 0.833 + }, + "o": + { + "x": 0.167, + "y": 0.167 + }, + "t": 0, + "s": + [ + 18.893, + 28.857, + 0 + ], + "to": + [ + 0, + 0, + 0 + ], + "ti": + [ + 0, + 0, + 0 + ] + }, + { + "t": 29, + "s": + [ + 2.176, + 22.824, + 0 + ] + } + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 1, + "k": + [ + { + "i": + { + "x": + [ + 0.833, + 0.833, + 0.833 + ], + "y": + [ + 0.833, + 0.833, + 0.833 + ] + }, + "o": + { + "x": + [ + 0.167, + 0.167, + 0.167 + ], + "y": + [ + 0.167, + 0.167, + 0.167 + ] + }, + "t": 0, + "s": + [ + 53.571, + 53.571, + 100 + ] + }, + { + "t": 29, + "s": + [ + 100, + 100, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0, + -1.1 + ], + [ + -1.1, + 0 + ], + [ + 0, + 1.1 + ], + [ + 1.1, + 0 + ] + ], + "o": + [ + [ + 0, + 1.1 + ], + [ + 1.1, + 0 + ], + [ + 0, + -1.1 + ], + [ + -1.1, + 0 + ] + ], + "v": + [ + [ + -2, + 0 + ], + [ + 0, + 2 + ], + [ + 2, + 0 + ], + [ + 0, + -2 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 0.22265625, + 0.411768317223, + 0.9375, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Dot", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_2", + "nm": "DuckDuckGo Icon", + "fr": 60, + "layers": + [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Ring", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 28, + 28, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "d": 1, + "ty": "el", + "s": + { + "a": 0, + "k": + [ + 49, + 49 + ], + "ix": 2 + }, + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 3 + }, + "nm": "Ellipse Path 1", + "mn": "ADBE Vector Shape - Ellipse", + "hd": false + }, + { + "ty": "st", + "c": + { + "a": 0, + "k": + [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": + { + "a": 0, + "k": 2, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Ring", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Eyes", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 80, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 26.452, + 22.241, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0.951, + 0 + ], + [ + 0.262, + -0.429 + ], + [ + -0.105, + 0.057 + ], + [ + -0.783, + -0.011 + ], + [ + -0.348, + -0.152 + ], + [ + -0.024, + -0.01 + ], + [ + 0.063, + 0.086 + ] + ], + "o": + [ + [ + -0.951, + 0 + ], + [ + -0.062, + 0.102 + ], + [ + 0.311, + -0.169 + ], + [ + 0.732, + 0.01 + ], + [ + 0.024, + 0.011 + ], + [ + 0.098, + 0.042 + ], + [ + -0.31, + -0.422 + ] + ], + "v": + [ + [ + 5.971, + -3.949 + ], + [ + 4.176, + -3.189 + ], + [ + 4.383, + -3.03 + ], + [ + 5.971, + -3.384 + ], + [ + 7.478, + -3.035 + ], + [ + 7.549, + -3.004 + ], + [ + 7.679, + -3.148 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 1, + "ty": "sh", + "ix": 2, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0.108, + 0.026 + ], + [ + 0.924, + -0.386 + ], + [ + 0.179, + -0.285 + ], + [ + -0.001, + 0.125 + ], + [ + -1.037, + 0.387 + ], + [ + -0.398, + -0.321 + ] + ], + "o": + [ + [ + -0.513, + -0.121 + ], + [ + -0.924, + 0.386 + ], + [ + -0.066, + 0.105 + ], + [ + 0.003, + -0.528 + ], + [ + 1.163, + -0.434 + ], + [ + 0.086, + 0.07 + ] + ], + "v": + [ + [ + -4.372, + -2.854 + ], + [ + -6.536, + -2.639 + ], + [ + -7.936, + -1.553 + ], + [ + -8.258, + -1.582 + ], + [ + -6.729, + -3.301 + ], + [ + -4.276, + -3.015 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 2", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 2, + "ty": "sh", + "ix": 3, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0, + -0.813 + ], + [ + -0.817, + 0 + ], + [ + 0, + 0.813 + ], + [ + 0.817, + 0 + ] + ], + "o": + [ + [ + 0, + 0.813 + ], + [ + 0.817, + 0 + ], + [ + 0, + -0.813 + ], + [ + -0.817, + 0 + ] + ], + "v": + [ + [ + 5.299, + 1.21 + ], + [ + 6.779, + 2.684 + ], + [ + 8.258, + 1.21 + ], + [ + 6.779, + -0.263 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 3", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 3, + "ty": "sh", + "ix": 4, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + -0.211, + 0 + ], + [ + 0, + -0.211 + ], + [ + 0.212, + 0 + ], + [ + 0, + 0.211 + ] + ], + "o": + [ + [ + 0.211, + 0 + ], + [ + 0, + 0.211 + ], + [ + -0.211, + 0 + ], + [ + 0.002, + -0.211 + ] + ], + "v": + [ + [ + 7.437, + 0.341 + ], + [ + 7.82, + 0.722 + ], + [ + 7.437, + 1.103 + ], + [ + 7.055, + 0.722 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 4", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 4, + "ty": "sh", + "ix": 5, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + -0.951, + 0 + ], + [ + 0, + 0.949 + ], + [ + 0.951, + 0 + ], + [ + 0, + -0.949 + ] + ], + "o": + [ + [ + 0.953, + 0 + ], + [ + 0, + -0.949 + ], + [ + -0.951, + 0 + ], + [ + 0, + 0.949 + ] + ], + "v": + [ + [ + -4.746, + 3.949 + ], + [ + -3.022, + 2.23 + ], + [ + -4.746, + 0.512 + ], + [ + -6.47, + 2.23 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 5", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 5, + "ty": "sh", + "ix": 6, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + -0.245, + 0 + ], + [ + 0, + -0.245 + ], + [ + 0.247, + 0 + ], + [ + 0, + 0.245 + ] + ], + "o": + [ + [ + 0.247, + 0 + ], + [ + 0, + 0.245 + ], + [ + -0.247, + 0 + ], + [ + 0.002, + -0.247 + ] + ], + "v": + [ + [ + -3.976, + 1.216 + ], + [ + -3.53, + 1.661 + ], + [ + -3.976, + 2.105 + ], + [ + -4.423, + 1.661 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 6", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "mm", + "mm": 1, + "nm": "Merge Paths 1", + "mn": "ADBE Vector Filter - Merge", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 0.078431375325, + 0.188235297799, + 0.494117647409, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Eyes", + "np": 8, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "Beak", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 34.137, + 30.779, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 1.976, + -0.117 + ], + [ + 0.179, + -1.035 + ], + [ + -1.377, + -0.988 + ], + [ + -4.691, + 0.297 + ], + [ + 0.525, + 0.874 + ], + [ + 4.723, + 0.418 + ], + [ + -0.404, + 0.7 + ], + [ + -3.569, + 0.494 + ], + [ + -1.058, + 1.351 + ], + [ + 0.255, + 0.412 + ], + [ + 1.656, + -0.739 + ], + [ + 2.404, + -0.408 + ] + ], + "o": + [ + [ + -3.911, + 0.168 + ], + [ + -0.23, + 1.354 + ], + [ + 1.086, + 0.779 + ], + [ + 4.691, + -0.297 + ], + [ + -0.525, + -0.874 + ], + [ + -3.495, + -0.31 + ], + [ + 0.936, + -1.622 + ], + [ + 3.569, + -0.494 + ], + [ + 0.554, + -0.707 + ], + [ + -0.232, + -0.375 + ], + [ + -1.171, + 0.522 + ], + [ + -1.617, + 0.274 + ] + ], + "v": + [ + [ + -4.431, + -3.203 + ], + [ + -9.51, + -0.015 + ], + [ + -7.688, + 3.842 + ], + [ + 1.113, + 5.844 + ], + [ + 7.917, + 2.69 + ], + [ + -1.097, + 3.1 + ], + [ + -4.617, + 0.517 + ], + [ + 2.317, + -0.768 + ], + [ + 8.92, + -3.722 + ], + [ + 9.451, + -5.589 + ], + [ + 6.955, + -5.336 + ], + [ + 1.617, + -3.722 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 1, + 0.800000011921, + 0.200000047684, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 3.15, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Beak", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 4, + "nm": "Bow Tie", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 30.76, + 43.904, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0.206, + 0.575 + ], + [ + 0.061, + 0.109 + ], + [ + 0, + -2.193 + ], + [ + -0.094, + 0.49 + ], + [ + 1.323, + 1.564 + ], + [ + 1.63, + -1.216 + ], + [ + 1.148, + -0.186 + ], + [ + -0.042, + -0.919 + ], + [ + -0.164, + -0.46 + ], + [ + -1.015, + 0.018 + ], + [ + -0.253, + 0.452 + ] + ], + "o": + [ + [ + -0.042, + -0.117 + ], + [ + 0.56, + 0.062 + ], + [ + 0, + 0.528 + ], + [ + 0.286, + -1.498 + ], + [ + -0.265, + -0.314 + ], + [ + -0.35, + -0.37 + ], + [ + -1.551, + 0.251 + ], + [ + -0.002, + 0.619 + ], + [ + 0.206, + 0.575 + ], + [ + 1.015, + -0.018 + ], + [ + 0.253, + -0.452 + ] + ], + "v": + [ + [ + 2.219, + -1.105 + ], + [ + 2.067, + -1.446 + ], + [ + 3.195, + 1.411 + ], + [ + 7.668, + 2.424 + ], + [ + 6.465, + -4.363 + ], + [ + 1.85, + -1.743 + ], + [ + -0.255, + -2.143 + ], + [ + -2.1, + -0.651 + ], + [ + -1.857, + 1.51 + ], + [ + -0.408, + 2.439 + ], + [ + 2.506, + 1.751 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 1, + "ty": "sh", + "ix": 2, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0, + 0 + ], + [ + 0.359, + -0.568 + ], + [ + -0.577, + -0.453 + ], + [ + 0.153, + 0.512 + ] + ], + "o": + [ + [ + -1.597, + -0.711 + ], + [ + -0.754, + 1.193 + ], + [ + 0.499, + 0.392 + ], + [ + -0.522, + -1.751 + ] + ], + "v": + [ + [ + -2.662, + -1.282 + ], + [ + -7.526, + -2.131 + ], + [ + -6.641, + 4.355 + ], + [ + -2.468, + 1.955 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 2", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "mm", + "mm": 1, + "nm": "Merge Paths 1", + "mn": "ADBE Vector Filter - Merge", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 0.298039227724, + 0.729411780834, + 0.235294118524, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Bow Tie", + "np": 4, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 4, + "nm": "Bow Tie Shadow", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 30.473, + 44.451, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + -0.31, + -0.439 + ], + [ + -0.582, + -0.27 + ], + [ + 1.478, + -0.222 + ], + [ + 1.442, + 0.539 + ], + [ + 0.017, + -0.032 + ], + [ + 1.133, + -0.026 + ], + [ + 0.226, + 0.319 + ], + [ + 0.562, + 0.361 + ], + [ + -0.81, + 1.126 + ], + [ + -1.824, + -0.691 + ], + [ + -1.155, + 0.201 + ] + ], + "o": + [ + [ + 1.485, + -1.232 + ], + [ + 0.684, + 0.317 + ], + [ + -0.567, + 0.085 + ], + [ + -0.013, + 0.043 + ], + [ + -0.285, + 0.528 + ], + [ + -0.749, + 0.017 + ], + [ + -1.403, + 1.191 + ], + [ + -1.076, + -0.692 + ], + [ + 0.741, + -1.03 + ], + [ + 0.285, + -0.48 + ], + [ + 1.32, + -0.23 + ] + ], + "v": + [ + [ + 2.218, + -2.151 + ], + [ + 6.522, + -4.718 + ], + [ + 7.412, + 2.72 + ], + [ + 2.864, + 1.695 + ], + [ + 2.819, + 1.808 + ], + [ + -0.437, + 2.626 + ], + [ + -1.778, + 2.142 + ], + [ + -6.43, + 4.699 + ], + [ + -7.708, + -2.448 + ], + [ + -2.076, + -1.72 + ], + [ + 0.002, + -2.68 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 0.236603736877, + 0.660000026226, + 0.17018866539, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Bow Tie Shadow", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 6, + "ty": 4, + "nm": "Subtract", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 26.052, + 30.837, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + -2.029, + 0 + ], + [ + -1.224, + 0.479 + ], + [ + -2.741, + 0.426 + ], + [ + 0.048, + 0.089 + ], + [ + -2.974, + -0.333 + ], + [ + -0.174, + 0.108 + ], + [ + 7.644, + 1.642 + ], + [ + 0.02, + 0.025 + ], + [ + 3.984, + -0.536 + ], + [ + -0.158, + -0.025 + ], + [ + -0.348, + -0.109 + ], + [ + 0.11, + -0.053 + ], + [ + 0.551, + -0.759 + ], + [ + -0.115, + 0.04 + ], + [ + -1.186, + -1.407 + ], + [ + 0.095, + -0.022 + ], + [ + -2.31, + -9.434 + ], + [ + 0, + 0 + ], + [ + -0.064, + -0.261 + ], + [ + -0.657, + -2.023 + ] + ], + "o": + [ + [ + 1.384, + 0 + ], + [ + -2.582, + -5.658 + ], + [ + 0.1, + -0.016 + ], + [ + -4.861, + -9.059 + ], + [ + 0.734, + 0.082 + ], + [ + 0.881, + -0.55 + ], + [ + -0.031, + -0.007 + ], + [ + -2.087, + -2.629 + ], + [ + -0.158, + 0.021 + ], + [ + 0.666, + 0.107 + ], + [ + 0.116, + 0.036 + ], + [ + -0.532, + 0.256 + ], + [ + -0.072, + 0.099 + ], + [ + 2.81, + -0.969 + ], + [ + 0.063, + 0.074 + ], + [ + -10.339, + 2.421 + ], + [ + 0, + 0 + ], + [ + 0.063, + 0.258 + ], + [ + 1.943, + 7.964 + ], + [ + 1.064, + 0.479 + ] + ], + "v": + [ + [ + 2.503, + 20.785 + ], + [ + 7.633, + 20.052 + ], + [ + 4.171, + 5.209 + ], + [ + 4.286, + 4.986 + ], + [ + 8.29, + -3.082 + ], + [ + 9.753, + -3.046 + ], + [ + 2.443, + -17.321 + ], + [ + 2.364, + -17.37 + ], + [ + -7.867, + -20.659 + ], + [ + -7.854, + -20.343 + ], + [ + -6.248, + -19.96 + ], + [ + -6.235, + -19.71 + ], + [ + -8.394, + -18.11 + ], + [ + -8.25, + -17.935 + ], + [ + -2.032, + -17.036 + ], + [ + -2.11, + -16.827 + ], + [ + -8.253, + 2.733 + ], + [ + -8.251, + 2.74 + ], + [ + -8.061, + 3.518 + ], + [ + -3.408, + 20.003 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 1, + 1, + 1, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Subtract", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 7, + "ty": 4, + "nm": "Head Shadow", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 24.847, + 31.379, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + -1.799, + -3.602 + ], + [ + -1.14, + -2.216 + ], + [ + 0, + 0 + ], + [ + 2.079, + 1.141 + ], + [ + 0.01, + 0.032 + ], + [ + 2.531, + 10.147 + ], + [ + -10.089, + 2.742 + ], + [ + 0.075, + 0.069 + ], + [ + 1.932, + -0.382 + ], + [ + -0.104, + 0.088 + ], + [ + -0.376, + 0.014 + ], + [ + 0.075, + 0.094 + ], + [ + 0.557, + 0.189 + ], + [ + -0.151, + 0.02 + ], + [ + -0.827, + -0.132 + ], + [ + -1.449, + -0.772 + ], + [ + 0.15, + -0.595 + ] + ], + "o": + [ + [ + 0.677, + 1.349 + ], + [ + 0, + 0 + ], + [ + 0.464, + 1.307 + ], + [ + -0.029, + -0.016 + ], + [ + -0.145, + -0.475 + ], + [ + -2.686, + -10.77 + ], + [ + 0.098, + -0.027 + ], + [ + -1.163, + -1.057 + ], + [ + -0.134, + 0.026 + ], + [ + 0.899, + -0.763 + ], + [ + 0.12, + -0.004 + ], + [ + -0.398, + -0.495 + ], + [ + -0.144, + -0.049 + ], + [ + 0.848, + -0.114 + ], + [ + 1.63, + 0.273 + ], + [ + 1.303, + 0.69 + ], + [ + -0.822, + 3.275 + ] + ], + "v": + [ + [ + 6.721, + 15.708 + ], + [ + 9.628, + 21.312 + ], + [ + 9.03, + 20.157 + ], + [ + -3.519, + 19.63 + ], + [ + -3.576, + 19.557 + ], + [ + -7.451, + 4.932 + ], + [ + -2.045, + -17.355 + ], + [ + -1.983, + -17.573 + ], + [ + -7.05, + -18.436 + ], + [ + -7.166, + -18.662 + ], + [ + -5.21, + -19.597 + ], + [ + -5.064, + -19.842 + ], + [ + -6.695, + -20.853 + ], + [ + -6.682, + -21.193 + ], + [ + -3.923, + -21.238 + ], + [ + 0.939, + -18.968 + ], + [ + 3.937, + 3.754 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 0.867647051811, + 0.867647051811, + 0.867647051811, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Head Shadow", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 8, + "ty": 4, + "nm": "DDG_App_Background", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 28, + 28, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 1.342, + -2.634 + ], + [ + 0, + -7.841 + ], + [ + 0, + 0 + ], + [ + -1.526, + -2.995 + ], + [ + -2.634, + -1.342 + ], + [ + -7.841, + 0 + ], + [ + 0, + 0 + ], + [ + -2.995, + 1.526 + ], + [ + -1.342, + 2.634 + ], + [ + 0, + 7.841 + ], + [ + 0, + 0 + ], + [ + 1.526, + 2.995 + ], + [ + 2.634, + 1.342 + ], + [ + 7.841, + 0 + ], + [ + 0, + 0 + ], + [ + 2.995, + -1.526 + ] + ], + "o": + [ + [ + -1.526, + 2.995 + ], + [ + 0, + 0 + ], + [ + 0, + 7.841 + ], + [ + 1.342, + 2.634 + ], + [ + 2.995, + 1.526 + ], + [ + 0, + 0 + ], + [ + 7.841, + 0 + ], + [ + 2.634, + -1.342 + ], + [ + 1.526, + -2.995 + ], + [ + 0, + 0 + ], + [ + 0, + -7.841 + ], + [ + -1.342, + -2.634 + ], + [ + -2.995, + -1.526 + ], + [ + 0, + 0 + ], + [ + -7.841, + 0 + ], + [ + -2.634, + 1.342 + ] + ], + "v": + [ + [ + -26.474, + -20.356 + ], + [ + -28, + -5.6 + ], + [ + -28, + 5.6 + ], + [ + -26.474, + 20.356 + ], + [ + -20.356, + 26.474 + ], + [ + -5.6, + 28 + ], + [ + 5.6, + 28 + ], + [ + 20.356, + 26.474 + ], + [ + 26.474, + 20.356 + ], + [ + 28, + 5.6 + ], + [ + 28, + -5.6 + ], + [ + 26.474, + -20.356 + ], + [ + 20.356, + -26.474 + ], + [ + 5.6, + -28 + ], + [ + -5.6, + -28 + ], + [ + -20.356, + -26.474 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 0.870588243008, + 0.345098048449, + 0.20000000298, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Backdrop", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_3", + "nm": "Mail Icon", + "fr": 60, + "layers": + [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Mail Icon", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 28, + 28, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -1.672, + 0 + ], + [ + -0.617, + 0.186 + ], + [ + -0.788, + 0.788 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0.058, + -0.03 + ], + [ + 1.568, + 0 + ], + [ + 0, + 0 + ], + [ + 0.599, + 0.305 + ], + [ + 0.055, + 0.033 + ] + ], + "o": + [ + [ + 0, + 0 + ], + [ + 1.276, + 1.276 + ], + [ + 0.639, + 0 + ], + [ + 0.997, + -0.301 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -0.056, + 0.034 + ], + [ + -0.599, + 0.305 + ], + [ + 0, + 0 + ], + [ + -1.568, + 0 + ], + [ + -0.058, + -0.029 + ], + [ + 0, + 0 + ] + ], + "v": + [ + [ + -7.115, + 0.92 + ], + [ + -4.618, + 3.416 + ], + [ + 0.002, + 5.33 + ], + [ + 1.897, + 5.05 + ], + [ + 4.621, + 3.416 + ], + [ + 7.118, + 0.92 + ], + [ + 18.554, + 12.355 + ], + [ + 18.382, + 12.45 + ], + [ + 15.431, + 12.756 + ], + [ + -15.431, + 12.756 + ], + [ + -18.382, + 12.45 + ], + [ + -18.552, + 12.356 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 1, + "ty": "sh", + "ix": 2, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0.305, + -0.599 + ], + [ + 0.033, + -0.056 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -0.038, + -0.075 + ], + [ + 0, + -1.568 + ], + [ + 0, + 0 + ] + ], + "o": + [ + [ + -0.03, + 0.058 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0.044, + 0.071 + ], + [ + 0.305, + 0.599 + ], + [ + 0, + 0 + ], + [ + 0, + 1.568 + ] + ], + "v": + [ + [ + 19.606, + 11.227 + ], + [ + 19.511, + 11.397 + ], + [ + 8.076, + -0.038 + ], + [ + 19.482, + -11.445 + ], + [ + 19.606, + -11.227 + ], + [ + 19.911, + -8.276 + ], + [ + 19.911, + 8.276 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 2", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 2, + "ty": "sh", + "ix": 3, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + -0.775, + 0.775 + ], + [ + 0, + 0 + ], + [ + 0.076, + 0.039 + ], + [ + 1.568, + 0 + ], + [ + 0, + 0 + ], + [ + 0.599, + -0.305 + ], + [ + 0.071, + -0.044 + ], + [ + 0, + 0 + ], + [ + -0.981, + -0.2 + ], + [ + -0.684, + 0.132 + ] + ], + "o": + [ + [ + 0, + 0 + ], + [ + -0.071, + -0.045 + ], + [ + -0.599, + -0.305 + ], + [ + 0, + 0 + ], + [ + -1.568, + 0 + ], + [ + -0.075, + 0.038 + ], + [ + 0, + 0 + ], + [ + 0.761, + 0.761 + ], + [ + 0.683, + 0.139 + ], + [ + 1, + -0.194 + ] + ], + "v": + [ + [ + 3.741, + 2.536 + ], + [ + 18.603, + -12.325 + ], + [ + 18.382, + -12.45 + ], + [ + 15.431, + -12.756 + ], + [ + -15.431, + -12.756 + ], + [ + -18.382, + -12.45 + ], + [ + -18.601, + -12.326 + ], + [ + -3.738, + 2.536 + ], + [ + -1.061, + 3.978 + ], + [ + 1.012, + 3.989 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 3", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 3, + "ty": "sh", + "ix": 4, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + -0.305, + 0.599 + ], + [ + -0.045, + 0.071 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0.03, + 0.059 + ], + [ + 0, + 1.568 + ], + [ + 0, + 0 + ] + ], + "o": + [ + [ + 0.038, + -0.075 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -0.034, + -0.056 + ], + [ + -0.305, + -0.599 + ], + [ + 0, + 0 + ], + [ + 0, + -1.568 + ] + ], + "v": + [ + [ + -19.606, + -11.227 + ], + [ + -19.481, + -11.447 + ], + [ + -8.073, + -0.038 + ], + [ + -19.51, + 11.399 + ], + [ + -19.606, + 11.227 + ], + [ + -19.911, + 8.276 + ], + [ + -19.911, + -8.276 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 4", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "mm", + "mm": 1, + "nm": "Merge Paths 1", + "mn": "ADBE Vector Filter - Merge", + "hd": false + }, + { + "ty": "gf", + "o": + { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": + { + "p": 3, + "k": + { + "a": 0, + "k": + [ + 0, + 0.969, + 0.969, + 0.969, + 0.5, + 0.973, + 0.973, + 0.973, + 1, + 0.976, + 0.976, + 0.976 + ], + "ix": 9 + } + }, + "s": + { + "a": 0, + "k": + [ + 0, + -12.756 + ], + "ix": 5 + }, + "e": + { + "a": 0, + "k": + [ + 0, + 12.756 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Mail Icon", + "np": 6, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Blue Gradient", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 28, + 28, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 1.342, + -2.634 + ], + [ + 0, + -7.841 + ], + [ + 0, + 0 + ], + [ + -1.526, + -2.995 + ], + [ + -2.634, + -1.342 + ], + [ + -7.841, + 0 + ], + [ + 0, + 0 + ], + [ + -2.995, + 1.526 + ], + [ + -1.342, + 2.634 + ], + [ + 0, + 7.841 + ], + [ + 0, + 0 + ], + [ + 1.526, + 2.995 + ], + [ + 2.634, + 1.342 + ], + [ + 7.841, + 0 + ], + [ + 0, + 0 + ], + [ + 2.995, + -1.526 + ] + ], + "o": + [ + [ + -1.526, + 2.995 + ], + [ + 0, + 0 + ], + [ + 0, + 7.841 + ], + [ + 1.342, + 2.634 + ], + [ + 2.995, + 1.526 + ], + [ + 0, + 0 + ], + [ + 7.841, + 0 + ], + [ + 2.634, + -1.342 + ], + [ + 1.526, + -2.995 + ], + [ + 0, + 0 + ], + [ + 0, + -7.841 + ], + [ + -1.342, + -2.634 + ], + [ + -2.995, + -1.526 + ], + [ + 0, + 0 + ], + [ + -7.841, + 0 + ], + [ + -2.634, + 1.342 + ] + ], + "v": + [ + [ + -26.474, + -20.356 + ], + [ + -28, + -5.6 + ], + [ + -28, + 5.6 + ], + [ + -26.474, + 20.356 + ], + [ + -20.356, + 26.474 + ], + [ + -5.6, + 28 + ], + [ + 5.6, + 28 + ], + [ + 20.356, + 26.474 + ], + [ + 26.474, + 20.356 + ], + [ + 28, + 5.6 + ], + [ + 28, + -5.6 + ], + [ + 26.474, + -20.356 + ], + [ + 20.356, + -26.474 + ], + [ + 5.6, + -28 + ], + [ + -5.6, + -28 + ], + [ + -20.356, + -26.474 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": + { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": + { + "p": 3, + "k": + { + "a": 0, + "k": + [ + 0, + 0.102, + 0.416, + 0.906, + 0.5, + 0.114, + 0.588, + 0.931, + 1, + 0.125, + 0.761, + 0.957 + ], + "ix": 9 + } + }, + "s": + { + "a": 0, + "k": + [ + 0, + -28 + ], + "ix": 5 + }, + "e": + { + "a": 0, + "k": + [ + 0, + 28 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Blue Gradient", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "Mail Icon", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 27.998, + 28.233, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ty": "rc", + "d": 1, + "s": + { + "a": 0, + "k": + [ + 39.822, + 25.511 + ], + "ix": 2 + }, + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 3, + "ix": 4 + }, + "nm": "Rectangle Path 1", + "mn": "ADBE Vector Shape - Rect", + "hd": false + }, + { + "ind": 1, + "ty": "sh", + "ix": 2, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 1.276, + 1.276 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -0.981, + -0.2 + ], + [ + -0.684, + 0.132 + ], + [ + -0.775, + 0.775 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0.997, + -0.301 + ], + [ + 0.639, + 0 + ] + ], + "o": + [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0.761, + 0.761 + ], + [ + 0.683, + 0.139 + ], + [ + 1, + -0.194 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -0.788, + 0.788 + ], + [ + -0.617, + 0.186 + ], + [ + -1.672, + 0 + ] + ], + "v": + [ + [ + -4.618, + 3.416 + ], + [ + -7.115, + 0.92 + ], + [ + -19.547, + 13.352 + ], + [ + -19.625, + 13.274 + ], + [ + -19.703, + 13.352 + ], + [ + -20.583, + 12.472 + ], + [ + -8.073, + -0.038 + ], + [ + -20.972, + -12.937 + ], + [ + -20.092, + -13.817 + ], + [ + -3.738, + 2.536 + ], + [ + -1.061, + 3.978 + ], + [ + 1.012, + 3.989 + ], + [ + 3.741, + 2.536 + ], + [ + 20.095, + -13.817 + ], + [ + 20.975, + -12.937 + ], + [ + 8.076, + -0.038 + ], + [ + 20.586, + 12.472 + ], + [ + 19.706, + 13.352 + ], + [ + 19.628, + 13.274 + ], + [ + 19.551, + 13.352 + ], + [ + 7.118, + 0.92 + ], + [ + 4.621, + 3.416 + ], + [ + 1.897, + 5.05 + ], + [ + 0.002, + 5.33 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "mm", + "mm": 3, + "nm": "Merge Paths 1", + "mn": "ADBE Vector Filter - Merge", + "hd": false + }, + { + "ty": "gf", + "o": + { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": + { + "p": 3, + "k": + { + "a": 0, + "k": + [ + 0, + 0.969, + 0.969, + 0.969, + 0.5, + 0.973, + 0.973, + 0.973, + 1, + 0.976, + 0.976, + 0.976 + ], + "ix": 9 + } + }, + "s": + { + "a": 0, + "k": + [ + 0, + -12.756 + ], + "ix": 5 + }, + "e": + { + "a": 0, + "k": + [ + 0, + 12.756 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Mail Icon", + "np": 4, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 4, + "nm": "Blue Gradient", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 28, + 28, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 1.342, + -2.634 + ], + [ + 0, + -7.841 + ], + [ + 0, + 0 + ], + [ + -1.526, + -2.995 + ], + [ + -2.634, + -1.342 + ], + [ + -7.841, + 0 + ], + [ + 0, + 0 + ], + [ + -2.995, + 1.526 + ], + [ + -1.342, + 2.634 + ], + [ + 0, + 7.841 + ], + [ + 0, + 0 + ], + [ + 1.526, + 2.995 + ], + [ + 2.634, + 1.342 + ], + [ + 7.841, + 0 + ], + [ + 0, + 0 + ], + [ + 2.995, + -1.526 + ] + ], + "o": + [ + [ + -1.526, + 2.995 + ], + [ + 0, + 0 + ], + [ + 0, + 7.841 + ], + [ + 1.342, + 2.634 + ], + [ + 2.995, + 1.526 + ], + [ + 0, + 0 + ], + [ + 7.841, + 0 + ], + [ + 2.634, + -1.342 + ], + [ + 1.526, + -2.995 + ], + [ + 0, + 0 + ], + [ + 0, + -7.841 + ], + [ + -1.342, + -2.634 + ], + [ + -2.995, + -1.526 + ], + [ + 0, + 0 + ], + [ + -7.841, + 0 + ], + [ + -2.634, + 1.342 + ] + ], + "v": + [ + [ + -26.474, + -20.356 + ], + [ + -28, + -5.6 + ], + [ + -28, + 5.6 + ], + [ + -26.474, + 20.356 + ], + [ + -20.356, + 26.474 + ], + [ + -5.6, + 28 + ], + [ + 5.6, + 28 + ], + [ + 20.356, + 26.474 + ], + [ + 26.474, + 20.356 + ], + [ + 28, + 5.6 + ], + [ + 28, + -5.6 + ], + [ + 26.474, + -20.356 + ], + [ + 20.356, + -26.474 + ], + [ + 5.6, + -28 + ], + [ + -5.6, + -28 + ], + [ + -20.356, + -26.474 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": + { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": + { + "p": 3, + "k": + { + "a": 0, + "k": + [ + 0, + 0.102, + 0.416, + 0.906, + 0.5, + 0.114, + 0.588, + 0.931, + 1, + 0.125, + 0.761, + 0.957 + ], + "ix": 9 + } + }, + "s": + { + "a": 0, + "k": + [ + 0, + -28 + ], + "ix": 5 + }, + "e": + { + "a": 0, + "k": + [ + 0, + 28 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Blue Gradient", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_4", + "nm": "Music Icon", + "fr": 60, + "layers": + [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Apple Music Icon", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 26.134, + 27.519, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0, + 0 + ], + [ + -0.868, + 0.179 + ], + [ + 0, + 0 + ], + [ + 0, + -0.788 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 1.815, + -0.588 + ], + [ + 0.844, + 0 + ], + [ + 0, + 2.406 + ], + [ + -0.008, + 0.1 + ], + [ + 0.004, + 0.001 + ], + [ + 0, + 0.003 + ], + [ + -2.953, + 0.155 + ], + [ + 0.007, + 0.954 + ], + [ + 0, + 0.043 + ], + [ + 0, + 0 + ], + [ + 0.579, + -0.119 + ], + [ + 0, + 0 + ], + [ + 0, + -0.443 + ], + [ + 0, + 0 + ], + [ + 1.816, + -0.587 + ], + [ + 0.843, + 0 + ], + [ + 0, + 2.406 + ], + [ + -0.008, + 0.1 + ], + [ + 0.004, + 0.001 + ], + [ + 0, + 0.004 + ], + [ + -2.953, + 0.155 + ], + [ + 0.007, + 0.954 + ], + [ + 0, + 0.043 + ], + [ + 0, + 0 + ] + ], + "o": + [ + [ + 0, + -0.886 + ], + [ + 0, + 0 + ], + [ + 0.772, + -0.159 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 3.768 + ], + [ + -0.697, + 0.324 + ], + [ + -2.749, + 0 + ], + [ + 0, + -0.102 + ], + [ + 0, + -0.004 + ], + [ + -0.003, + -0.001 + ], + [ + 0.005, + -2.486 + ], + [ + 2.819, + -0.148 + ], + [ + 0, + -0.046 + ], + [ + 0, + 0 + ], + [ + 0, + -0.591 + ], + [ + 0, + 0 + ], + [ + -0.434, + 0.089 + ], + [ + 0, + 0 + ], + [ + 0, + 3.77 + ], + [ + -0.696, + 0.324 + ], + [ + -2.749, + 0 + ], + [ + 0, + -0.102 + ], + [ + 0, + -0.004 + ], + [ + -0.003, + -0.001 + ], + [ + 0.005, + -2.486 + ], + [ + 2.819, + -0.148 + ], + [ + 0, + -0.046 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": + [ + [ + -6.222, + -13.553 + ], + [ + -4.732, + -15.382 + ], + [ + 13.438, + -19.122 + ], + [ + 14.933, + -17.904 + ], + [ + 14.933, + -11.03 + ], + [ + 14.934, + -11.029 + ], + [ + 14.934, + 8.882 + ], + [ + 11.359, + 14.751 + ], + [ + 9.023, + 15.26 + ], + [ + 4.045, + 10.904 + ], + [ + 4.057, + 10.601 + ], + [ + 4.051, + 10.594 + ], + [ + 4.045, + 10.587 + ], + [ + 9.956, + 5.926 + ], + [ + 12.757, + 3.26 + ], + [ + 12.756, + 3.126 + ], + [ + 12.756, + -8.815 + ], + [ + 11.635, + -9.729 + ], + [ + -3.3, + -6.654 + ], + [ + -4.045, + -5.74 + ], + [ + -4.045, + 12.771 + ], + [ + -7.623, + 18.642 + ], + [ + -9.956, + 19.148 + ], + [ + -14.934, + 14.793 + ], + [ + -14.922, + 14.491 + ], + [ + -14.928, + 14.483 + ], + [ + -14.934, + 14.476 + ], + [ + -9.023, + 9.815 + ], + [ + -6.222, + 7.149 + ], + [ + -6.223, + 7.015 + ], + [ + -6.222, + -7.141 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 1, + "ty": "sh", + "ix": 2, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0, + 0 + ] + ], + "o": + [ + [ + 0, + 0 + ] + ], + "v": + [ + [ + -6.222, + -7.141 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 2", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "mm", + "mm": 1, + "nm": "Merge Paths 1", + "mn": "ADBE Vector Filter - Merge", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 1, + 1, + 1, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Apple Music Icon", + "np": 4, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Red/Pink Gradient", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 28, + 28, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 1.342, + -2.634 + ], + [ + 0, + -7.841 + ], + [ + 0, + 0 + ], + [ + -1.526, + -2.995 + ], + [ + -2.634, + -1.342 + ], + [ + -7.841, + 0 + ], + [ + 0, + 0 + ], + [ + -2.995, + 1.526 + ], + [ + -1.342, + 2.634 + ], + [ + 0, + 7.841 + ], + [ + 0, + 0 + ], + [ + 1.526, + 2.995 + ], + [ + 2.634, + 1.342 + ], + [ + 7.841, + 0 + ], + [ + 0, + 0 + ], + [ + 2.995, + -1.526 + ] + ], + "o": + [ + [ + -1.526, + 2.995 + ], + [ + 0, + 0 + ], + [ + 0, + 7.841 + ], + [ + 1.342, + 2.634 + ], + [ + 2.995, + 1.526 + ], + [ + 0, + 0 + ], + [ + 7.841, + 0 + ], + [ + 2.634, + -1.342 + ], + [ + 1.526, + -2.995 + ], + [ + 0, + 0 + ], + [ + 0, + -7.841 + ], + [ + -1.342, + -2.634 + ], + [ + -2.995, + -1.526 + ], + [ + 0, + 0 + ], + [ + -7.841, + 0 + ], + [ + -2.634, + 1.342 + ] + ], + "v": + [ + [ + -26.474, + -20.356 + ], + [ + -28, + -5.6 + ], + [ + -28, + 5.6 + ], + [ + -26.474, + 20.356 + ], + [ + -20.356, + 26.474 + ], + [ + -5.6, + 28 + ], + [ + 5.6, + 28 + ], + [ + 20.356, + 26.474 + ], + [ + 26.474, + 20.356 + ], + [ + 28, + 5.6 + ], + [ + 28, + -5.6 + ], + [ + 26.474, + -20.356 + ], + [ + 20.356, + -26.474 + ], + [ + 5.6, + -28 + ], + [ + -5.6, + -28 + ], + [ + -20.356, + -26.474 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": + { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": + { + "p": 3, + "k": + { + "a": 0, + "k": + [ + 0, + 0.984, + 0.349, + 0.463, + 0.5, + 0.982, + 0.245, + 0.347, + 1, + 0.98, + 0.141, + 0.231 + ], + "ix": 9 + } + }, + "s": + { + "a": 0, + "k": + [ + 0, + -28 + ], + "ix": 5 + }, + "e": + { + "a": 0, + "k": + [ + 0, + 28 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Red/Pink Gradient", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_5", + "nm": "Phone Icon", + "fr": 60, + "layers": + [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Phone Icon", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 26, + 29.5, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + -100, + -100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 0, + 2.419 + ], + [ + -12.6, + 0 + ], + [ + 0, + -4.822 + ], + [ + 0.575, + -0.883 + ], + [ + 0.45, + 0.1 + ], + [ + 0, + 0 + ], + [ + 3.354, + 0.629 + ], + [ + 0.373, + 1.742 + ], + [ + 0.622, + 0.622 + ], + [ + 5.444, + 0 + ], + [ + 0.622, + -0.622 + ], + [ + 0.467, + -2.178 + ], + [ + 0.83, + -0.156 + ], + [ + 1.971, + -0.437 + ], + [ + 0.071, + -0.016 + ], + [ + 0.252, + 0.386 + ] + ], + "o": + [ + [ + 0, + -4.822 + ], + [ + 12.6, + 0 + ], + [ + 0, + 2.419 + ], + [ + -0.252, + 0.386 + ], + [ + 0, + 0 + ], + [ + -1.971, + -0.437 + ], + [ + -0.83, + -0.156 + ], + [ + -0.467, + -2.178 + ], + [ + -0.622, + -0.622 + ], + [ + -5.444, + 0 + ], + [ + -0.622, + 0.622 + ], + [ + -0.373, + 1.742 + ], + [ + -3.354, + 0.629 + ], + [ + -0.075, + 0.017 + ], + [ + -0.45, + 0.1 + ], + [ + -0.575, + -0.883 + ] + ], + "v": + [ + [ + -22.711, + 1.855 + ], + [ + 0, + -7.167 + ], + [ + 22.711, + 1.855 + ], + [ + 21.446, + 6.698 + ], + [ + 20.268, + 7.138 + ], + [ + 20.049, + 7.089 + ], + [ + 11.356, + 5.277 + ], + [ + 9.644, + 2.944 + ], + [ + 8.089, + -0.634 + ], + [ + 0, + -1.723 + ], + [ + -8.089, + -0.634 + ], + [ + -9.644, + 2.944 + ], + [ + -11.356, + 5.277 + ], + [ + -20.049, + 7.089 + ], + [ + -20.268, + 7.138 + ], + [ + -21.446, + 6.698 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 1, + 1, + 1, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 45, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Phone Icon", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Green Gradient", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 28, + 28, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 1.342, + -2.634 + ], + [ + 0, + -7.841 + ], + [ + 0, + 0 + ], + [ + -1.526, + -2.995 + ], + [ + -2.634, + -1.342 + ], + [ + -7.841, + 0 + ], + [ + 0, + 0 + ], + [ + -2.995, + 1.526 + ], + [ + -1.342, + 2.634 + ], + [ + 0, + 7.841 + ], + [ + 0, + 0 + ], + [ + 1.526, + 2.995 + ], + [ + 2.634, + 1.342 + ], + [ + 7.841, + 0 + ], + [ + 0, + 0 + ], + [ + 2.995, + -1.526 + ] + ], + "o": + [ + [ + -1.526, + 2.995 + ], + [ + 0, + 0 + ], + [ + 0, + 7.841 + ], + [ + 1.342, + 2.634 + ], + [ + 2.995, + 1.526 + ], + [ + 0, + 0 + ], + [ + 7.841, + 0 + ], + [ + 2.634, + -1.342 + ], + [ + 1.526, + -2.995 + ], + [ + 0, + 0 + ], + [ + 0, + -7.841 + ], + [ + -1.342, + -2.634 + ], + [ + -2.995, + -1.526 + ], + [ + 0, + 0 + ], + [ + -7.841, + 0 + ], + [ + -2.634, + 1.342 + ] + ], + "v": + [ + [ + -26.474, + -20.356 + ], + [ + -28, + -5.6 + ], + [ + -28, + 5.6 + ], + [ + -26.474, + 20.356 + ], + [ + -20.356, + 26.474 + ], + [ + -5.6, + 28 + ], + [ + 5.6, + 28 + ], + [ + 20.356, + 26.474 + ], + [ + 26.474, + 20.356 + ], + [ + 28, + 5.6 + ], + [ + 28, + -5.6 + ], + [ + 26.474, + -20.356 + ], + [ + 20.356, + -26.474 + ], + [ + 5.6, + -28 + ], + [ + -5.6, + -28 + ], + [ + -20.356, + -26.474 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": + { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": + { + "p": 3, + "k": + { + "a": 0, + "k": + [ + 0, + 0.373, + 0.965, + 0.467, + 0.5, + 0.206, + 0.849, + 0.322, + 1, + 0.039, + 0.733, + 0.176 + ], + "ix": 9 + } + }, + "s": + { + "a": 0, + "k": + [ + 0, + -28 + ], + "ix": 5 + }, + "e": + { + "a": 0, + "k": + [ + 0, + 28 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Green Gradient", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_6", + "nm": "Background-Vector", + "fr": 60, + "layers": + [ + { + "ddd": 0, + "ind": 1, + "ty": 2, + "nm": "Dock-Blur", + "refId": "image_0", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 155, + 52, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 294, + 88, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 50, + 50, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Stroke", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 153.914, + 49.174, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + -29.295, + 13.198 + ], + [ + -48.757, + 6.506 + ], + [ + -0.127, + -0.25 + ], + [ + -0.106, + -0.227 + ], + [ + 13.03, + -6.452 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 68.15, + 0 + ], + [ + 11.373, + 1.034 + ], + [ + 0.087, + 0.525 + ], + [ + -13.912, + 0 + ] + ], + "o": + [ + [ + 13.165, + -6.516 + ], + [ + 0.134, + 0.246 + ], + [ + 0.113, + 0.222 + ], + [ + -48.931, + 6.483 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -29.38, + 13.239 + ], + [ + -13.786, + 0 + ], + [ + -0.103, + -0.497 + ], + [ + 11.436, + 1.053 + ], + [ + 67.841, + 0 + ] + ], + "v": + [ + [ + 57.766, + -8.635 + ], + [ + 152.207, + -32.445 + ], + [ + 152.598, + -31.702 + ], + [ + 152.927, + -31.028 + ], + [ + 58.419, + -7.285 + ], + [ + 58.407, + -7.279 + ], + [ + 58.394, + -7.273 + ], + [ + -114.914, + 32.445 + ], + [ + -152.644, + 30.803 + ], + [ + -152.927, + 29.271 + ], + [ + -114.914, + 30.945 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": + { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": + { + "p": 3, + "k": + { + "a": 0, + "k": + [ + 0, + 0.016, + 0.106, + 0.137, + 0.5, + 0.008, + 0.178, + 0.569, + 1, + 0, + 0.25, + 1, + 0, + 1, + 0.5, + 0.82, + 1, + 0.64 + ], + "ix": 9 + } + }, + "s": + { + "a": 0, + "k": + [ + -152.927, + 0 + ], + "ix": 5 + }, + "e": + { + "a": 0, + "k": + [ + 152.927, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Stroke", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "Wave Front", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 155.56, + 60.718, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 3.488, + 6.845 + ], + [ + 0.006, + 0.012 + ], + [ + 13.094, + -6.484 + ], + [ + 68, + 0 + ], + [ + 11.407, + 1.043 + ], + [ + -1.099, + -2.158 + ], + [ + -6.021, + -3.068 + ], + [ + -17.922, + 0 + ], + [ + 0, + 0 + ], + [ + -6.845, + 3.488 + ], + [ + -3.068, + 6.021 + ], + [ + 0, + 17.922 + ], + [ + 0, + 0 + ] + ], + "o": + [ + [ + -0.006, + -0.012 + ], + [ + -48.858, + 6.495 + ], + [ + -29.333, + 13.217 + ], + [ + -13.852, + 0 + ], + [ + 0.516, + 2.792 + ], + [ + 3.068, + 6.021 + ], + [ + 6.845, + 3.488 + ], + [ + 0, + 0 + ], + [ + 17.922, + 0 + ], + [ + 6.021, + -3.068 + ], + [ + 3.488, + -6.845 + ], + [ + 0, + 0 + ], + [ + 0, + -17.922 + ] + ], + "v": + [ + [ + 150.952, + -43.245 + ], + [ + 150.933, + -43.282 + ], + [ + 56.44, + -19.5 + ], + [ + -116.56, + 20.152 + ], + [ + -154.44, + 18.492 + ], + [ + -152.072, + 25.81 + ], + [ + -138.088, + 39.794 + ], + [ + -104.36, + 43.282 + ], + [ + 103.24, + 43.282 + ], + [ + 136.968, + 39.794 + ], + [ + 150.952, + 25.81 + ], + [ + 154.44, + -7.918 + ], + [ + 154.44, + -9.518 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": + { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 8, + "g": + { + "p": 5, + "k": + { + "a": 0, + "k": + [ + 0, + 0.02, + 0.11, + 0.153, + 0.353, + 0.01, + 0.194, + 0.541, + 0.706, + 0, + 0.279, + 0.93, + 0.853, + 0, + 0.319, + 0.965, + 1, + 0, + 0.36, + 1, + 0, + 1, + 0.353, + 0.82, + 0.706, + 0.64, + 0.853, + 0.4, + 1, + 0.16 + ], + "ix": 9 + } + }, + "s": + { + "a": 0, + "k": + [ + -134.56, + -22.718 + ], + "ix": 5 + }, + "e": + { + "a": 0, + "k": + [ + 166.94, + 0.282 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Wave Front", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 8 + }, + { + "ddd": 0, + "ind": 4, + "ty": 4, + "nm": "Wave Back", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 90.16, + 52, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "ef": + [ + { + "ty": 25, + "nm": "Drop Shadow", + "np": 8, + "mn": "ADBE Drop Shadow", + "ix": 1, + "en": 1, + "ef": + [ + { + "ty": 2, + "nm": "Shadow Color", + "mn": "ADBE Drop Shadow-0001", + "ix": 1, + "v": + { + "a": 0, + "k": + [ + 0, + 0.32382813096, + 0.971484363079, + 0.319999992847 + ], + "ix": 1 + } + }, + { + "ty": 0, + "nm": "Opacity", + "mn": "ADBE Drop Shadow-0002", + "ix": 2, + "v": + { + "a": 0, + "k": 102, + "ix": 2 + } + }, + { + "ty": 0, + "nm": "Direction", + "mn": "ADBE Drop Shadow-0003", + "ix": 3, + "v": + { + "a": 0, + "k": 90, + "ix": 3 + } + }, + { + "ty": 0, + "nm": "Distance", + "mn": "ADBE Drop Shadow-0004", + "ix": 4, + "v": + { + "a": 0, + "k": 2, + "ix": 4 + } + }, + { + "ty": 0, + "nm": "Softness", + "mn": "ADBE Drop Shadow-0005", + "ix": 5, + "v": + { + "a": 0, + "k": 0, + "ix": 5 + } + }, + { + "ty": 7, + "nm": "Shadow Only", + "mn": "ADBE Drop Shadow-0006", + "ix": 6, + "v": + { + "a": 0, + "k": 0, + "ix": 6 + } + } + ] + }, + { + "ty": 25, + "nm": "Drop Shadow 2", + "np": 8, + "mn": "ADBE Drop Shadow", + "ix": 2, + "en": 1, + "ef": + [ + { + "ty": 2, + "nm": "Shadow Color", + "mn": "ADBE Drop Shadow-0001", + "ix": 1, + "v": + { + "a": 0, + "k": + [ + 0, + 0.32549020648, + 0.972549021244, + 0.800000011921 + ], + "ix": 1 + } + }, + { + "ty": 0, + "nm": "Opacity", + "mn": "ADBE Drop Shadow-0002", + "ix": 2, + "v": + { + "a": 0, + "k": 255, + "ix": 2 + } + }, + { + "ty": 0, + "nm": "Direction", + "mn": "ADBE Drop Shadow-0003", + "ix": 3, + "v": + { + "a": 0, + "k": 90, + "ix": 3 + } + }, + { + "ty": 0, + "nm": "Distance", + "mn": "ADBE Drop Shadow-0004", + "ix": 4, + "v": + { + "a": 0, + "k": 4, + "ix": 4 + } + }, + { + "ty": 0, + "nm": "Softness", + "mn": "ADBE Drop Shadow-0005", + "ix": 5, + "v": + { + "a": 0, + "k": 4, + "ix": 5 + } + }, + { + "ty": 7, + "nm": "Shadow Only", + "mn": "ADBE Drop Shadow-0006", + "ix": 6, + "v": + { + "a": 0, + "k": 0, + "ix": 6 + } + } + ] + } + ], + "sy": + [ + { + "c": + { + "a": 0, + "k": + [ + 1, + 1, + 1, + 0.639999985695 + ], + "ix": 2 + }, + "o": + { + "a": 0, + "k": 64, + "ix": 3 + }, + "a": + { + "a": 0, + "k": 135, + "ix": 5 + }, + "s": + { + "a": 0, + "k": 25, + "ix": 8 + }, + "d": + { + "a": 0, + "k": 1.414, + "ix": 6 + }, + "ch": + { + "a": 0, + "k": 0, + "ix": 7 + }, + "bm": + { + "a": 0, + "k": 1, + "ix": 1 + }, + "no": + { + "a": 0, + "k": 0, + "ix": 9 + }, + "ty": 2, + "nm": "Inner Shadow" + } + ], + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": + { + "a": 0, + "k": + { + "i": + [ + [ + 3.068, + 6.021 + ], + [ + 0, + 17.922 + ], + [ + 0, + 0 + ], + [ + -3.488, + 6.845 + ], + [ + -6.021, + 3.068 + ], + [ + -17.922, + 0 + ], + [ + 0, + 0 + ], + [ + -48.13, + -57.299 + ], + [ + 0, + 0 + ], + [ + 6.845, + 3.488 + ] + ], + "o": + [ + [ + -3.488, + -6.845 + ], + [ + 0, + 0 + ], + [ + 0, + -17.922 + ], + [ + 3.068, + -6.021 + ], + [ + 6.845, + -3.488 + ], + [ + 0, + 0 + ], + [ + 40.779, + 19.206 + ], + [ + 0, + 0 + ], + [ + -17.922, + 0 + ], + [ + -6.021, + -3.068 + ] + ], + "v": + [ + [ + -86.672, + 34.528 + ], + [ + -90.16, + 0.8 + ], + [ + -90.16, + -0.8 + ], + [ + -86.672, + -34.528 + ], + [ + -72.688, + -48.512 + ], + [ + -38.96, + -52 + ], + [ + -28.425, + -52 + ], + [ + 90.16, + 52 + ], + [ + -38.96, + 52 + ], + [ + -72.688, + 48.512 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": + { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": + { + "p": 3, + "k": + { + "a": 0, + "k": + [ + 0, + 1, + 1, + 1, + 0.5, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0.64, + 0.5, + 0.32, + 1, + 0 + ], + "ix": 9 + } + }, + "s": + { + "a": 0, + "k": + [ + 51.107, + -52 + ], + "ix": 5 + }, + "e": + { + "a": 0, + "k": + [ + -64.124, + 77.782 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Wave Back", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 4, + "nm": "Background-Gradient-Pink", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 155, + 52, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ty": "rc", + "d": 1, + "s": + { + "a": 0, + "k": + [ + 310, + 104 + ], + "ix": 2 + }, + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 32, + "ix": 4 + }, + "nm": "Rectangle Path 1", + "mn": "ADBE Vector Shape - Rect", + "hd": false + }, + { + "ty": "gf", + "o": + { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": + { + "p": 3, + "k": + { + "a": 0, + "k": + [ + 0.069, + 0.96, + 0.754, + 0.712, + 0.535, + 0.96, + 0.684, + 0.781, + 1, + 0.96, + 0.613, + 0.85, + 0.069, + 1, + 0.535, + 0.5, + 1, + 0 + ], + "ix": 9 + } + }, + "s": + { + "a": 0, + "k": + [ + 149, + -42.5 + ], + "ix": 5 + }, + "e": + { + "a": 0, + "k": + [ + 1.04, + 40.5 + ], + "ix": 6 + }, + "t": 2, + "h": + { + "a": 0, + "k": 14.188, + "ix": 7 + }, + "a": + { + "a": 0, + "k": 87.81, + "ix": 8 + }, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Background-Gradient-Pink", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 6, + "ty": 4, + "nm": "Background-Gradient-Blue", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 155, + 52, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ty": "rc", + "d": 1, + "s": + { + "a": 0, + "k": + [ + 310, + 104 + ], + "ix": 2 + }, + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 32, + "ix": 4 + }, + "nm": "Rectangle Path 1", + "mn": "ADBE Vector Shape - Rect", + "hd": false + }, + { + "ty": "gf", + "o": + { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": + { + "p": 3, + "k": + { + "a": 0, + "k": + [ + 0, + 0.588, + 0.855, + 0.992, + 0.5, + 0.588, + 0.855, + 0.992, + 1, + 0.588, + 0.855, + 0.992, + 0, + 0.64, + 0.5, + 0.32, + 1, + 0 + ], + "ix": 9 + } + }, + "s": + { + "a": 0, + "k": + [ + -149.371, + -52 + ], + "ix": 5 + }, + "e": + { + "a": 0, + "k": + [ + 160.629, + -52 + ], + "ix": 6 + }, + "t": 2, + "h": + { + "a": 0, + "k": 0, + "ix": 7 + }, + "a": + { + "a": 0, + "k": 0, + "ix": 8 + }, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Background-Gradient-Blue", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 7, + "ty": 4, + "nm": "Background-Base", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 155, + 52, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": + [ + { + "ty": "gr", + "it": + [ + { + "ty": "rc", + "d": 1, + "s": + { + "a": 0, + "k": + [ + 310, + 104 + ], + "ix": 2 + }, + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 32, + "ix": 4 + }, + "nm": "Rectangle Path 1", + "mn": "ADBE Vector Shape - Rect", + "hd": false + }, + { + "ty": "fl", + "c": + { + "a": 0, + "k": + [ + 0.321568638086, + 0.686274528503, + 0.909803926945, + 1 + ], + "ix": 4 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 2 + }, + "a": + { + "a": 0, + "k": + [ + 0, + 0 + ], + "ix": 1 + }, + "s": + { + "a": 0, + "k": + [ + 100, + 100 + ], + "ix": 3 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": + { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": + { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": + { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Background-Base", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + } + ] + } + ], + "layers": + [ + { + "ddd": 0, + "ind": 1, + "ty": 0, + "nm": "Pre-comp 1", + "refId": "comp_0", + "sr": 1, + "ks": + { + "o": + { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": + { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": + { + "a": 0, + "k": + [ + 77.5, + 26, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": + { + "a": 0, + "k": + [ + 155, + 52, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": + { + "a": 0, + "k": + [ + 50, + 50, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 310, + "h": 104, + "ip": 0, + "op": 455, + "st": 0, + "bm": 0 + } + ], + "markers": + [] +} diff --git a/DuckDuckGo/OnboardingExperiment/AppIconPicker/AppIconPickerViewModel.swift b/DuckDuckGo/OnboardingExperiment/AppIconPicker/AppIconPickerViewModel.swift index ceaebee301..2310d99572 100644 --- a/DuckDuckGo/OnboardingExperiment/AppIconPicker/AppIconPickerViewModel.swift +++ b/DuckDuckGo/OnboardingExperiment/AppIconPicker/AppIconPickerViewModel.swift @@ -50,8 +50,11 @@ final class AppIconPickerViewModel: ObservableObject { } } -protocol AppIconManaging { +protocol AppIconProviding { var appIcon: AppIcon { get } +} + +protocol AppIconManaging: AppIconProviding { func changeAppIcon(_ appIcon: AppIcon, completionHandler: ((Error?) -> Void)?) } diff --git a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift index 88a7b6c42a..f5bb5d47ec 100644 --- a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift +++ b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift @@ -185,45 +185,96 @@ struct OnboardingTrackersDoneDialog: View { struct OnboardingFinalDialog: View { let title = UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenTitle - let message: String let cta = UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenButton - - let highFiveAction: () -> Void + + let logoPosition: DaxDialogLogoPosition + let message: String + let canShowAddToDockTutorial: Bool + let dismissAction: (_ fromAddToDock: Bool) -> Void + + @State private var showAddToDockTutorial = false var body: some View { ScrollView(.vertical, showsIndicators: false) { - DaxDialogView(logoPosition: .left) { - ContextualDaxDialogContent( - title: title, - titleFont: Font(UIFont.daxTitle3()), - message: NSAttributedString(string: message), - customActionView: AnyView( - OnboardingCTAButton( - title: cta, - action: highFiveAction - ) + DaxDialogView(logoPosition: logoPosition) { + if showAddToDockTutorial { + OnboardingAddToDockTutorialContent { + dismissAction(true) + } + } else { + ContextualDaxDialogContent( + title: title, + titleFont: Font(UIFont.daxTitle3()), + message: NSAttributedString(string: message), + customView: AnyView(customView), + customActionView: AnyView(customActionView) ) - ) + } } .padding() } } + + @ViewBuilder + private var customView: some View { + if canShowAddToDockTutorial { + AddToDockPromoView() + .aspectRatio(contentMode: .fill) + .padding(.vertical) + } else { + EmptyView() + } + } + + @ViewBuilder + private var customActionView: some View { + VStack { + if canShowAddToDockTutorial { + OnboardingCTAButton( + title: UserText.AddToDockOnboarding.Buttons.addToDockTutorial, + action: { + showAddToDockTutorial = true + } + ) + } + OnboardingCTAButton( + title: cta, + buttonStyle: canShowAddToDockTutorial ? .ghost : .primary, + action: { + dismissAction(false) + } + ) + } + } } struct OnboardingCTAButton: View { + enum ButtonStyle { + case primary + case ghost + } + let title: String + var buttonStyle: ButtonStyle = .primary let action: () -> Void + var body: some View { - Button(action: action) { + let button = Button(action: action) { Text(title) } - .buttonStyle(PrimaryButtonStyle(compact: true)) + + switch buttonStyle { + case .primary: + button.buttonStyle(PrimaryButtonStyle(compact: true)) + case .ghost: + button.buttonStyle(GhostButtonStyle()) + } } } -struct OnboardingAddToDockDialog: View { +struct OnboardingAddToDockTutorialContent: View { let title = UserText.AddToDockOnboarding.Tutorial.title let message = UserText.AddToDockOnboarding.Tutorial.message let cta = UserText.AddToDockOnboarding.Buttons.dismiss @@ -231,15 +282,10 @@ struct OnboardingAddToDockDialog: View { let dismissAction: () -> Void var body: some View { - ScrollView(.vertical, showsIndicators: false) { - DaxDialogView(logoPosition: .top) { - AddToDockTutorialView( - title: title, - message: message, - action: dismissAction) - } - .padding() - } + AddToDockTutorialView( + title: title, + message: message, + action: dismissAction) } } @@ -272,9 +318,24 @@ struct OnboardingAddToDockDialog: View { .padding() } -#Preview("Final Dialog") { - OnboardingFinalDialog(message: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage, highFiveAction: {}) - .padding() +#Preview("Final Dialog - No Add to Dock Tutorial") { + OnboardingFinalDialog( + logoPosition: .top, + message: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage, + canShowAddToDockTutorial: false, + dismissAction: { _ in } + ) + .padding() +} + +#Preview("Final Dialog - Add to Dock Tutorial") { + OnboardingFinalDialog( + logoPosition: .left, + message: UserText.AddToDockOnboarding.EndOfJourney.message, + canShowAddToDockTutorial: true, + dismissAction: { _ in } + ) + .padding() } #Preview("Trackers Dialog") { @@ -292,11 +353,11 @@ struct OnboardingAddToDockDialog: View { } #Preview("Add To Dock Tutorial - Light") { - OnboardingAddToDockDialog(dismissAction: {}) + OnboardingAddToDockTutorialContent(dismissAction: {}) .preferredColorScheme(.light) } #Preview("Add To Dock Tutorial - Dark") { - OnboardingAddToDockDialog(dismissAction: {}) + OnboardingAddToDockTutorialContent(dismissAction: {}) .preferredColorScheme(.dark) } diff --git a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/NewTabDaxDialogFactory.swift b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/NewTabDaxDialogFactory.swift index bdb43ef753..c4f74f8217 100644 --- a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/NewTabDaxDialogFactory.swift +++ b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/NewTabDaxDialogFactory.swift @@ -99,21 +99,22 @@ final class NewTabDaxDialogFactory: NewTabDaxDialogProvider { } private func createFinalDialog(onDismiss: @escaping () -> Void) -> some View { - // TODO: Update views - if onboardingManager.isAddToDockEnabled { - Logger.onboarding.debug("Present Final Dialog with Add To Dock updates") + let message = if onboardingManager.isAddToDockEnabled { + UserText.AddToDockOnboarding.EndOfJourney.message } else { - Logger.onboarding.debug("Present Final Dialog without Add To Dock updates") + onboardingManager.isOnboardingHighlightsEnabled ? UserText.HighlightsOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage : UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage } - let message = onboardingManager.isOnboardingHighlightsEnabled ? UserText.HighlightsOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage : UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage - return FadeInView { - OnboardingFinalDialog(message: message, highFiveAction: { [weak self] in - self?.onboardingPixelReporter.trackEndOfJourneyDialogCTAAction() + OnboardingFinalDialog(logoPosition: .top, message: message, canShowAddToDockTutorial: onboardingManager.isAddToDockEnabled) { [weak self] isDismissedFromAddToDock in + if isDismissedFromAddToDock { + Logger.onboarding.debug("Dismissed from add to dock") + } else { + Logger.onboarding.debug("Dismissed from end of Journey") + self?.onboardingPixelReporter.trackEndOfJourneyDialogCTAAction() + } onDismiss() - }) - .onboardingDaxDialogStyle() + } } .onboardingContextualBackgroundStyle(background: .illustratedGradient(gradientType)) .onFirstAppear { [weak self] in diff --git a/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift index a2ef0ce102..b6b9f08289 100644 --- a/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift +++ b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift @@ -182,18 +182,20 @@ final class ExperimentContextualDaxDialogsFactory: ContextualDaxDialogsFactory { } private func endOfJourneyDialog(delegate: ContextualOnboardingDelegate, pixelName: Pixel.Event) -> some View { - // TODO: Update views - if onboardingManager.isAddToDockEnabled { - Logger.onboarding.debug("Present Contextual Final Dialog with Add To Dock updates") + let message = if onboardingManager.isAddToDockEnabled { + UserText.AddToDockOnboarding.EndOfJourney.message } else { - Logger.onboarding.debug("Present Contextual Final Dialog without Add To Dock updates") + onboardingManager.isOnboardingHighlightsEnabled ? UserText.HighlightsOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage : UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage } - let message = onboardingManager.isOnboardingHighlightsEnabled ? UserText.HighlightsOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage : UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage - - return OnboardingFinalDialog(message: message, highFiveAction: { [weak delegate, weak self] in + return OnboardingFinalDialog(logoPosition: .left, message: message, canShowAddToDockTutorial: onboardingManager.isAddToDockEnabled, dismissAction: { [weak delegate, weak self] isDismissedFromAddToDock in delegate?.didTapDismissContextualOnboardingAction() - self?.contextualOnboardingPixelReporter.trackEndOfJourneyDialogCTAAction() + if isDismissedFromAddToDock { + Logger.onboarding.debug("Dismissed from add to dock") + } else { + Logger.onboarding.debug("Dismissed from end of Journey") + self?.contextualOnboardingPixelReporter.trackEndOfJourneyDialogCTAAction() + } }) .onFirstAppear { [weak self] in self?.contextualOnboardingLogic.setFinalOnboardingDialogSeen() diff --git a/DuckDuckGoTests/AddToDockPromoViewModelTests.swift b/DuckDuckGoTests/AddToDockPromoViewModelTests.swift new file mode 100644 index 0000000000..d8e7b22ee9 --- /dev/null +++ b/DuckDuckGoTests/AddToDockPromoViewModelTests.swift @@ -0,0 +1,97 @@ +// +// AddToDockPromoViewModelTests.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Lottie +@testable import DuckDuckGo + +final class AddToDockPromoViewModelTests: XCTestCase { + private var sut: AddToDockPromoViewModel! + private var managerMock: AppIconManagerMock! + + override func setUpWithError() throws { + try super.setUpWithError() + + managerMock = .init() + sut = .init(appIconManager: managerMock) + } + + override func tearDownWithError() throws { + managerMock = nil + sut = nil + try super.tearDownWithError() + } + + func testWhenColorIsCalledThenReturnExpectedColor() { + // GIVEN + managerMock.appIcon = .red + + // WHEN + var result = sut.color + + // THEN + XCTAssertEqual(result, LottieColor(r: 0.87, g: 0.34, b: 0.2, a: 1.0)) + + // GIVEN + managerMock.appIcon = .yellow + + // WHEN + result = sut.color + + // THEN + XCTAssertEqual(result, LottieColor(r: 0.89, g: 0.64, b: 0.07, a: 1.0)) + + // GIVEN + managerMock.appIcon = .green + + // WHEN + result = sut.color + + // THEN + XCTAssertEqual(result, LottieColor(r: 0.22, g: 0.62, b: 0.16, a: 1.0)) + + // GIVEN + managerMock.appIcon = .blue + + // WHEN + result = sut.color + + // THEN + XCTAssertEqual(result, LottieColor(r: 0.22, g: 0.41, b: 0.94, a: 1.0)) + + // GIVEN + managerMock.appIcon = .purple + + // WHEN + result = sut.color + + // THEN + XCTAssertEqual(result, LottieColor(r: 0.42, g: 0.31, b: 0.73, a: 1.0)) + + // GIVEN + managerMock.appIcon = .black + + // WHEN + result = sut.color + + // THEN + XCTAssertEqual(result, LottieColor(r: 0, g: 0, b: 0, a: 1.0)) + } + +} diff --git a/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift b/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift index cf4b1ba610..7f7665d7a6 100644 --- a/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift +++ b/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift @@ -202,7 +202,7 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { XCTAssertFalse(delegate.didCallDidTapDismissContextualOnboardingAction) // WHEN - view.highFiveAction() + view.dismissAction(false) // THEN XCTAssertTrue(delegate.didCallDidTapDismissContextualOnboardingAction) @@ -330,7 +330,7 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { XCTAssertFalse(pixelReporterMock.didCallTrackEndOfJourneyDialogDismiss) // WHEN - view.highFiveAction() + view.dismissAction(false) // THEN XCTAssertTrue(pixelReporterMock.didCallTrackEndOfJourneyDialogDismiss) diff --git a/DuckDuckGoTests/ContextualOnboardingNewTabDialogFactoryTests.swift b/DuckDuckGoTests/ContextualOnboardingNewTabDialogFactoryTests.swift index 152b5523e4..8d54d53d5a 100644 --- a/DuckDuckGoTests/ContextualOnboardingNewTabDialogFactoryTests.swift +++ b/DuckDuckGoTests/ContextualOnboardingNewTabDialogFactoryTests.swift @@ -101,7 +101,7 @@ class ContextualOnboardingNewTabDialogFactoryTests: XCTestCase { // Then let finalDialog = find(OnboardingFinalDialog.self, in: host) XCTAssertNotNil(finalDialog) - finalDialog?.highFiveAction() + finalDialog?.dismissAction(false) XCTAssertTrue(onDismissedRun) wait(for: [expectation], timeout: 5.0) XCTAssertTrue(contextualOnboardingLogicMock.didCallsetFinalOnboardingDialogSeen) @@ -157,7 +157,7 @@ class ContextualOnboardingNewTabDialogFactoryTests: XCTestCase { XCTAssertFalse(pixelReporterMock.didCallTrackEndOfJourneyDialogDismiss) // WHEN - finalDialog.highFiveAction() + finalDialog.dismissAction(false) // THEN XCTAssertTrue(pixelReporterMock.didCallTrackEndOfJourneyDialogDismiss) From 62f91545858022ed10a43a4ac7ca243a8d8ea62b Mon Sep 17 00:00:00 2001 From: Anh Do <18567+quanganhdo@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:00:12 -0400 Subject: [PATCH 04/29] BSK changes for macOS release note screen update (#3466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1201037661562251/1208183525704010/f Tech Design URL: CC: **Description**: Updates BSK. **Steps to test this PR**: 1. Check macOS PR 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) --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 790620a494..b968e6f87f 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10978,7 +10978,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 202.0.0; + version = 202.1.0; }; }; 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 f92fe51779..f182f09943 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" : "de77673bd4fa7b8012c8d1f16cbc73b064539a57", - "version" : "202.0.0" + "revision" : "8a1bc5526e14c589ca2cc74e6e7d125952b79bc1", + "version" : "202.1.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "b74549bd869fdecc16fad851f2f608b1724764df", - "version" : "6.25.0" + "revision" : "48fee2508995d4ac02d18b3d55424adedcb4ce4f", + "version" : "6.28.0" } }, { From 2d750c951de25e7d3aa13e06b31b2419eaa1b313 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Thu, 31 Oct 2024 21:06:43 +0100 Subject: [PATCH 05/29] Set version_check_wait_retry_limit to 1 (#3511) Task/Issue URL: https://app.asana.com/0/1203301625297703/1208664774795332/f --- fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0cadad0296..1d0d12d932 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -221,7 +221,7 @@ lane :upload_metadata do |options| deliver(common_deliver_arguments.merge(options).merge({ skip_binary_upload: true, skip_metadata: false, - version_check_wait_retry_limit: 0 + version_check_wait_retry_limit: 1 })) end From 36c702ea20b99a31512ae5c540ab8fdb9092787a Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 31 Oct 2024 22:32:22 -0700 Subject: [PATCH 06/29] Fix VPN server info metadata (#3501) Task/Issue URL: https://app.asana.com/0/1207603085593419/1208640706072126/f Tech Design URL: CC: Description: This PR fixes the server info metadata. --- DuckDuckGo/Feedback/VPNFeedbackFormView.swift | 5 ++++- DuckDuckGo/Feedback/VPNMetadataCollector.swift | 7 +++++-- DuckDuckGo/NetworkProtectionDebugViewController.swift | 5 ++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/DuckDuckGo/Feedback/VPNFeedbackFormView.swift b/DuckDuckGo/Feedback/VPNFeedbackFormView.swift index b7562586b9..2a4469cdc6 100644 --- a/DuckDuckGo/Feedback/VPNFeedbackFormView.swift +++ b/DuckDuckGo/Feedback/VPNFeedbackFormView.swift @@ -22,7 +22,10 @@ import NetworkProtection struct VPNFeedbackFormCategoryView: View { @Environment(\.dismiss) private var dismiss - let collector = DefaultVPNMetadataCollector(statusObserver: AppDependencyProvider.shared.connectionObserver) + let collector = DefaultVPNMetadataCollector( + statusObserver: AppDependencyProvider.shared.connectionObserver, + serverInfoObserver: AppDependencyProvider.shared.serverInfoObserver + ) var body: some View { VStack { diff --git a/DuckDuckGo/Feedback/VPNMetadataCollector.swift b/DuckDuckGo/Feedback/VPNMetadataCollector.swift index a2f6b69669..f77dcfddd2 100644 --- a/DuckDuckGo/Feedback/VPNMetadataCollector.swift +++ b/DuckDuckGo/Feedback/VPNMetadataCollector.swift @@ -107,7 +107,7 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { private let defaults: UserDefaults init(statusObserver: ConnectionStatusObserver, - serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession(), + serverInfoObserver: ConnectionServerInfoObserver, accountManager: AccountManager = AppDependencyProvider.shared.subscriptionManager.accountManager, settings: VPNSettings = .init(defaults: .networkProtectionGroupDefaults), defaults: UserDefaults = .networkProtectionGroupDefaults) { @@ -277,7 +277,10 @@ extension VPNMetadata: UnifiedFeedbackMetadata {} extension DefaultVPNMetadataCollector: UnifiedMetadataCollector { convenience init() { - self.init(statusObserver: AppDependencyProvider.shared.connectionObserver) + self.init( + statusObserver: AppDependencyProvider.shared.connectionObserver, + serverInfoObserver: AppDependencyProvider.shared.serverInfoObserver + ) } func collectMetadata() async -> VPNMetadata? { diff --git a/DuckDuckGo/NetworkProtectionDebugViewController.swift b/DuckDuckGo/NetworkProtectionDebugViewController.swift index 9af0df4f9f..0c0c34568e 100644 --- a/DuckDuckGo/NetworkProtectionDebugViewController.swift +++ b/DuckDuckGo/NetworkProtectionDebugViewController.swift @@ -643,7 +643,10 @@ shouldShowVPNShortcut: \(vpnVisibility.shouldShowVPNShortcut() ? "YES" : "NO") @MainActor private func refreshMetadata() async { - let collector = DefaultVPNMetadataCollector(statusObserver: AppDependencyProvider.shared.connectionObserver) + let collector = DefaultVPNMetadataCollector( + statusObserver: AppDependencyProvider.shared.connectionObserver, + serverInfoObserver: AppDependencyProvider.shared.serverInfoObserver + ) self.vpnMetadata = await collector.collectMetadata() self.tableView.reloadData() } From eda599ee1cea2a5b9520d30592a6bc374af37d5c Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Fri, 1 Nov 2024 10:16:30 +0100 Subject: [PATCH 07/29] Blind attempt to fix Omnibar-related crash (#3514) Task/Issue URL: https://app.asana.com/0/414709148257752/1208653357224780/f Description: Attempt to fix Omnibar crash: These changes were already reviewed and merged to main in https://github.com/duckduckgo/iOS/pull/3500 --- DuckDuckGo/OmniBar.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/OmniBar.swift b/DuckDuckGo/OmniBar.swift index d12d00d8e9..61850c6f2c 100644 --- a/DuckDuckGo/OmniBar.swift +++ b/DuckDuckGo/OmniBar.swift @@ -384,8 +384,8 @@ class OmniBar: UIView { searchStackContainer.setCustomSpacing(13, after: voiceSearchButton) } - UIView.animate(withDuration: 0.0) { - self.layoutIfNeeded() + UIView.animate(withDuration: 0.0) { [weak self] in + self?.layoutIfNeeded() } } From 02437949f017381438a7c64ec9d3c26cad460b32 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Fri, 1 Nov 2024 11:30:46 +0100 Subject: [PATCH 08/29] Duckplayer New Tab + Refactor (#3491) Task/Issue URL: https://app.asana.com/0/1204099484721401/1208366641490350/f **Description**: - Refactors DuckPlayerNavigation handler for improved navigation and stability - Implements Open In New Tab Behavior - Opens "Watch in Youtube" links in new tab by default - Fixes issues found in Open in New tab closed ship review - Improves navigation for "Default" mode to prevent full screen videos (https://app.asana.com/0/1204099484721401/1208528683217627/f) - Updates pixel logic - Removes DuckPlayer Launch Experiment logic and pixels --- Core/PixelEvent.swift | 22 +- DuckDuckGo.xcodeproj/project.pbxproj | 8 - DuckDuckGo/Debug.storyboard | 28 - DuckDuckGo/DuckPlayer/DuckPlayer.swift | 275 +++-- .../DuckPlayerLaunchExperiment.swift | 239 ----- .../DuckPlayerNavigationHandler.swift | 997 ++++++++++++------ .../DuckPlayerNavigationHandling.swift | 136 ++- .../DuckPlayer/DuckPlayerSettings.swift | 66 +- .../DuckPlayer/YoutubeOverlayUserScript.swift | 4 +- .../DuckPlayer/YoutubePlayerUserScript.swift | 4 +- DuckDuckGo/OmniBar.swift | 5 +- DuckDuckGo/RootDebugViewController.swift | 12 - DuckDuckGo/SettingsMainSettingsView.swift | 10 +- DuckDuckGo/TabManager.swift | 2 +- DuckDuckGo/TabViewController.swift | 123 ++- DuckDuckGo/UserScripts.swift | 2 +- DuckDuckGo/UserText.swift | 2 +- DuckDuckGo/en.lproj/Localizable.strings | 2 +- .../DuckPlayerExperimentTests.swift | 435 -------- DuckDuckGoTests/DuckPlayerMocks.swift | 135 ++- ...YoutublePlayerNavigationHandlerTests.swift | 851 ++++++++------- 21 files changed, 1734 insertions(+), 1624 deletions(-) delete mode 100644 DuckDuckGo/DuckPlayer/DuckPlayerLaunchExperiment.swift delete mode 100644 DuckDuckGoTests/DuckPlayerExperimentTests.swift diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index ad4d2a7a02..ddee9d5f7d 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -799,6 +799,10 @@ extension Pixel { case duckPlayerSettingAlwaysSettings case duckPlayerSettingNeverSettings case duckPlayerSettingBackToDefault + case duckPlayerSettingsAlwaysOverlaySERP + case duckPlayerSettingsAlwaysOverlayYoutube + case duckPlayerSettingsNeverOverlaySERP + case duckPlayerSettingsNeverOverlayYoutube case duckPlayerWatchOnYoutube case duckPlayerSettingAlwaysOverlayYoutube case duckPlayerSettingNeverOverlayYoutube @@ -826,13 +830,6 @@ extension Pixel { case pproFeedbackSubcategoryScreenShow(source: String, reportType: String, category: String) case pproFeedbackSubmitScreenShow(source: String, reportType: String, category: String, subcategory: String) case pproFeedbackSubmitScreenFAQClick(source: String, reportType: String, category: String, subcategory: String) - - // MARK: DuckPlayer Pixel Experiment - case duckplayerExperimentCohortAssign - case duckplayerExperimentSearch - case duckplayerExperimentDailySearch - case duckplayerExperimentWeeklySearch - case duckplayerExperimentYoutubePageView // MARK: WebView Error Page Shown case webViewErrorPageShown @@ -1623,6 +1620,10 @@ extension Pixel.Event { case .duckPlayerViewFromOther: return "duckplayer_view-from_other" case .duckPlayerSettingAlwaysSettings: return "duckplayer_setting_always_settings" case .duckPlayerSettingAlwaysDuckPlayer: return "duckplayer_setting_always_duck-player" + case .duckPlayerSettingsAlwaysOverlaySERP: return "duckplayer_setting_always_overlay_serp" + case .duckPlayerSettingsAlwaysOverlayYoutube: return "duckplayer_setting_always_overlay_youtube" + case .duckPlayerSettingsNeverOverlaySERP: return "duckplayer_setting_never_overlay_serp" + case .duckPlayerSettingsNeverOverlayYoutube: return "duckplayer_setting_never_overlay_youtube" case .duckPlayerOverlayYoutubeImpressions: return "duckplayer_overlay_youtube_impressions" case .duckPlayerOverlayYoutubeWatchHere: return "duckplayer_overlay_youtube_watch_here" case .duckPlayerSettingNeverSettings: return "duckplayer_setting_never_settings" @@ -1656,13 +1657,6 @@ extension Pixel.Event { case .pproFeedbackSubmitScreenShow: return "m_ppro_feedback_submit-screen_show" case .pproFeedbackSubmitScreenFAQClick: return "m_ppro_feedback_submit-screen-faq_click" - // MARK: Duckplayer experiment - case .duckplayerExperimentCohortAssign: return "duckplayer_experiment_cohort_assign_v2" - case .duckplayerExperimentSearch: return "duckplayer_experiment_search_v2" - case .duckplayerExperimentDailySearch: return "duckplayer_experiment_daily_search_v2" - case .duckplayerExperimentWeeklySearch: return "duckplayer_experiment_weekly_search_v2" - case .duckplayerExperimentYoutubePageView: return "duckplayer_experiment_youtube_page_view_v2" - // MARK: - WebView Error Page shown case .webViewErrorPageShown: return "m_errorpageshown" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index b968e6f87f..5fd3444f82 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -968,7 +968,6 @@ D60170BD2BA34CE8001911B5 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60170BB2BA32DD6001911B5 /* Subscription.swift */; }; D6037E692C32F2E7009AAEC0 /* DuckPlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6037E682C32F2E7009AAEC0 /* DuckPlayerSettings.swift */; }; D60B1F272B9DDE5A00AE4760 /* SubscriptionGoogleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */; }; - D60E5C2F2C862297007D6BC7 /* DuckPlayerLaunchExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E5C2E2C862297007D6BC7 /* DuckPlayerLaunchExperiment.swift */; }; D61CDA162B7CF77300A0FBB9 /* Subscription in Frameworks */ = {isa = PBXBuildFile; productRef = D61CDA152B7CF77300A0FBB9 /* Subscription */; }; D61CDA182B7CF78300A0FBB9 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = D61CDA172B7CF78300A0FBB9 /* ZIPFoundation */; }; D625AAEC2BBEF27600BC189A /* TabURLInterceptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D625AAEA2BBEEFC900BC189A /* TabURLInterceptorTests.swift */; }; @@ -1024,7 +1023,6 @@ D6E83C602B22B3C9006C8AFB /* SettingsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E83C5F2B22B3C9006C8AFB /* SettingsState.swift */; }; D6E83C662B23936F006C8AFB /* SettingsDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E83C652B23936F006C8AFB /* SettingsDebugView.swift */; }; D6E83C682B23B6A3006C8AFB /* FontSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E83C672B23B6A3006C8AFB /* FontSettings.swift */; }; - D6F557BA2C8859040034444B /* DuckPlayerExperimentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F557B92C8859040034444B /* DuckPlayerExperimentTests.swift */; }; D6F93E3C2B4FFA97004C268D /* SubscriptionDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F93E3B2B4FFA97004C268D /* SubscriptionDebugViewController.swift */; }; D6F93E3E2B50A8A0004C268D /* SubscriptionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F93E3D2B50A8A0004C268D /* SubscriptionSettingsView.swift */; }; D6FEB8B12B7498A300C3615F /* HeadlessWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FEB8B02B7498A300C3615F /* HeadlessWebView.swift */; }; @@ -2779,7 +2777,6 @@ D60170BB2BA32DD6001911B5 /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; D6037E682C32F2E7009AAEC0 /* DuckPlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerSettings.swift; sourceTree = ""; }; D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionGoogleView.swift; sourceTree = ""; }; - D60E5C2E2C862297007D6BC7 /* DuckPlayerLaunchExperiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerLaunchExperiment.swift; sourceTree = ""; }; D625AAEA2BBEEFC900BC189A /* TabURLInterceptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabURLInterceptorTests.swift; sourceTree = ""; }; D62EC3B82C246A5600FC9D04 /* YoutublePlayerNavigationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YoutublePlayerNavigationHandlerTests.swift; sourceTree = ""; }; D62EC3BB2C2470E000FC9D04 /* DuckPlayerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerTests.swift; sourceTree = ""; }; @@ -2831,7 +2828,6 @@ D6E83C5F2B22B3C9006C8AFB /* SettingsState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsState.swift; sourceTree = ""; }; D6E83C652B23936F006C8AFB /* SettingsDebugView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsDebugView.swift; sourceTree = ""; }; D6E83C672B23B6A3006C8AFB /* FontSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontSettings.swift; sourceTree = ""; }; - D6F557B92C8859040034444B /* DuckPlayerExperimentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerExperimentTests.swift; sourceTree = ""; }; D6F93E3B2B4FFA97004C268D /* SubscriptionDebugViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionDebugViewController.swift; sourceTree = ""; }; D6F93E3D2B50A8A0004C268D /* SubscriptionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionSettingsView.swift; sourceTree = ""; }; D6FEB8B02B7498A300C3615F /* HeadlessWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessWebView.swift; sourceTree = ""; }; @@ -5335,7 +5331,6 @@ D6B67A112C332B6E002122EB /* DuckPlayerMocks.swift */, D62EC3BB2C2470E000FC9D04 /* DuckPlayerTests.swift */, D62EC3B82C246A5600FC9D04 /* YoutublePlayerNavigationHandlerTests.swift */, - D6F557B92C8859040034444B /* DuckPlayerExperimentTests.swift */, ); name = DuckPlayer; sourceTree = ""; @@ -5352,7 +5347,6 @@ D63FF8942C1B67E8006DE24D /* YoutubeOverlayUserScript.swift */, D63FF8932C1B67E8006DE24D /* YoutubePlayerUserScript.swift */, 31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */, - D60E5C2E2C862297007D6BC7 /* DuckPlayerLaunchExperiment.swift */, ); path = DuckPlayer; sourceTree = ""; @@ -7834,7 +7828,6 @@ F4F6DFB826EA9AA600ED7E12 /* BookmarksTextFieldCell.swift in Sources */, 6FE127402C204D9B00EB5724 /* ShortcutsView.swift in Sources */, 85F98F92296F32BD00742F4A /* SyncSettingsViewController.swift in Sources */, - D60E5C2F2C862297007D6BC7 /* DuckPlayerLaunchExperiment.swift in Sources */, 84E341961E2F7EFB00BDBA6F /* AppDelegate.swift in Sources */, 310D091D2799F57200DC0060 /* Download.swift in Sources */, BDE91CDA2C62A70B0005CB74 /* UnifiedMetadataCollector.swift in Sources */, @@ -7981,7 +7974,6 @@ F1BDDBFD2C340D9C00459306 /* SubscriptionContainerViewModelTests.swift in Sources */, 987243142C5232B5007ECC76 /* BookmarksDatabaseSetupTests.swift in Sources */, CBDD5DDF29A6736A00832877 /* APIHeadersTests.swift in Sources */, - D6F557BA2C8859040034444B /* DuckPlayerExperimentTests.swift in Sources */, 986B45D0299E30A50089D2D7 /* BookmarkEntityTests.swift in Sources */, 981C49B02C8FA61D00DF11E8 /* DataStoreIdManagerTests.swift in Sources */, B6AD9E3828D4512E0019CDE9 /* EmbeddedTrackerDataTests.swift in Sources */, diff --git a/DuckDuckGo/Debug.storyboard b/DuckDuckGo/Debug.storyboard index 6f8d490805..ebc359a9e2 100644 --- a/DuckDuckGo/Debug.storyboard +++ b/DuckDuckGo/Debug.storyboard @@ -374,15 +374,6 @@ - - - - - - - - - @@ -392,24 +383,6 @@ - - - - - - - - - - - - - - - - - - @@ -759,7 +732,6 @@ - diff --git a/DuckDuckGo/DuckPlayer/DuckPlayer.swift b/DuckDuckGo/DuckPlayer/DuckPlayer.swift index b511962c39..486688dce2 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayer.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayer.swift @@ -26,7 +26,7 @@ import UserScript import Core import ContentScopeScripts -/// Values that the Frontend can use to determine the current state. +/// Values that the frontend can use to determine the current state. struct InitialPlayerSettings: Codable { struct PlayerSettings: Codable { let pip: PIP @@ -58,7 +58,7 @@ struct InitialPlayerSettings: Codable { let localeStrings: String? } -/// Values that the Frontend can use to determine user settings +/// Values that the frontend can use to determine user settings. public struct UserValues: Codable { enum CodingKeys: String, CodingKey { case duckPlayerMode = "privatePlayerMode" @@ -68,6 +68,7 @@ public struct UserValues: Codable { let askModeOverlayHidden: Bool } +/// UI-related values for the frontend. public struct UIValues: Codable { enum CodingKeys: String, CodingKey { case allowFirstVideo @@ -75,23 +76,6 @@ public struct UIValues: Codable { let allowFirstVideo: Bool } -public enum DuckPlayerReferrer { - case youtube, other, serp - - // Computed property to get string values - var stringValue: String { - switch self { - case .youtube: - return "youtube" - case .serp: - return "serp" - default: - return "other" - - } - } -} - // Wrapper to allow sibling properties on each event in the future. struct TelemetryEvent: Decodable { let attributes: Attributes @@ -136,27 +120,94 @@ enum Attributes: Decodable { } } -protocol DuckPlayerProtocol: AnyObject { + +/// Protocol defining the Duck Player functionality. +protocol DuckPlayerControlling: AnyObject { + /// The current Duck Player settings. var settings: DuckPlayerSettings { get } + + /// The host view controller, if any. var hostView: UIViewController? { get } + /// Initializes a new instance of DuckPlayer with the provided settings and feature flagger. + /// + /// - Parameters: + /// - settings: The Duck Player settings. + /// - featureFlagger: The feature flag manager. init(settings: DuckPlayerSettings, featureFlagger: FeatureFlagger) + /// Sets user values received from the web content. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. func setUserValues(params: Any, message: WKScriptMessage) -> Encodable? + + /// Retrieves user values to send to the web content. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. func getUserValues(params: Any, message: WKScriptMessage) -> Encodable? + + /// Opens a video in Duck Player within the specified web view. + /// + /// - Parameters: + /// - url: The URL of the video. + /// - webView: The web view to load the video in. func openVideoInDuckPlayer(url: URL, webView: WKWebView) + + /// Opens Duck Player settings. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. func openDuckPlayerSettings(params: Any, message: WKScriptMessage) async -> Encodable? + + /// Opens Duck Player information modal. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. func openDuckPlayerInfo(params: Any, message: WKScriptMessage) async -> Encodable? + + /// Sends a telemetry event from the FE. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. func telemetryEvent(params: Any, message: WKScriptMessage) async -> Encodable? - + + /// Performs initial setup for the player. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> Encodable? + + /// Performs initial setup for the overlay. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. func initialSetupOverlay(params: Any, message: WKScriptMessage) async -> Encodable? + /// Sets the host view controller for presenting modals. + /// + /// - Parameter vc: The view controller to set as host. func setHostViewController(_ vc: UIViewController) + + /// Removes the host view controller. + func removeHostView() } -final class DuckPlayer: DuckPlayerProtocol { +/// Implementation of the DuckPlayerControlling. +final class DuckPlayer: DuckPlayerControlling { struct Constants { static let duckPlayerHost: String = "player" @@ -168,8 +219,10 @@ final class DuckPlayer: DuckPlayerProtocol { static let featureNameKey = "featureName" } + private(set) var settings: DuckPlayerSettings private(set) weak var hostView: UIViewController? + private var featureFlagger: FeatureFlagger private lazy var localeStrings: String? = { @@ -193,20 +246,37 @@ final class DuckPlayer: DuckPlayerProtocol { case overlay = "duckPlayer" } + /// Initializes a new instance of DuckPlayer with the provided settings and feature flagger. + /// + /// - Parameters: + /// - settings: The Duck Player settings. + /// - featureFlagger: The feature flag manager. init(settings: DuckPlayerSettings = DuckPlayerSettingsDefault(), featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger) { self.settings = settings self.featureFlagger = featureFlagger } - // Sets a presenting VC, so DuckPlayer can present the - // info sheet directly + /// Sets the host view controller for presenting modals. + /// + /// - Parameter vc: The view controller to set as host. public func setHostViewController(_ vc: UIViewController) { hostView = vc } + /// Removes the host view controller. + public func removeHostView() { + hostView = nil + } + // MARK: - Common Message Handlers + /// Sets user values received from the web content. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. public func setUserValues(params: Any, message: WKScriptMessage) -> Encodable? { guard let userValues: UserValues = DecodableHelper.decode(from: params) else { assertionFailure("DuckPlayer: expected JSON representation of UserValues") @@ -214,49 +284,75 @@ final class DuckPlayer: DuckPlayerProtocol { } Task { - // Fires pixels + // Fire pixels for analytics await firePixels(message: message, userValues: userValues) - // Update Settings + // Update settings based on user values await updateSettings(userValues: userValues) } return userValues } - + + /// Updates Duck Player settings based on user values. + /// + /// - Parameter userValues: The user values to update settings with. private func updateSettings(userValues: UserValues) async { settings.setMode(userValues.duckPlayerMode) settings.setAskModeOverlayHidden(userValues.askModeOverlayHidden) } + /// Retrieves user values to send to the web content. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. public func getUserValues(params: Any, message: WKScriptMessage) -> Encodable? { - // If the user is in the 'control' group, or DP is disabled sending 'nil' effectively disables - // Duckplayer in SERP, showing old overlays. - // Fixes: https://app.asana.com/0/1207252092703676/1208450923559111 - let duckPlayerExperiment = DuckPlayerLaunchExperiment() - if featureFlagger.isFeatureOn(.duckPlayer) && duckPlayerExperiment.isEnrolled && duckPlayerExperiment.isExperimentCohort { + if featureFlagger.isFeatureOn(.duckPlayer) { return encodeUserValues() } return nil - } + /// Opens a video in Duck Player within the specified web view. + /// + /// - Parameters: + /// - url: The URL of the video. + /// - webView: The web view to load the video in. @MainActor public func openVideoInDuckPlayer(url: URL, webView: WKWebView) { webView.load(URLRequest(url: url)) } + /// Performs initial setup for the player. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. @MainActor public func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> Encodable? { let webView = message.webView return await self.encodedPlayerSettings(with: webView) } + /// Performs initial setup for the overlay. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. + /// - Returns: An optional `Encodable` response. @MainActor public func initialSetupOverlay(params: Any, message: WKScriptMessage) async -> Encodable? { let webView = message.webView return await self.encodedPlayerSettings(with: webView) } + /// Opens Duck Player settings. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. public func openDuckPlayerSettings(params: Any, message: WKScriptMessage) async -> Encodable? { NotificationCenter.default.post( name: .settingsDeepLinkNotification, @@ -266,12 +362,33 @@ final class DuckPlayer: DuckPlayerProtocol { return nil } + /// Sends a telemetry event from the FE. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. @MainActor - public func presentDuckPlayerInfo(context: DuckPlayerModalPresenter.PresentationContext) { - guard let hostView else { return } - DuckPlayerModalPresenter(context: context).presentDuckPlayerFeatureModal(on: hostView) - } + public func telemetryEvent(params: Any, message: WKScriptMessage) async -> Encodable? { + guard let event: TelemetryEvent = DecodableHelper.decode(from: params) else { + return nil + } + + switch event.attributes { + case .impression(let attrs): + switch attrs.value { + case .landscape: + Pixel.fire(pixel: .duckPlayerLandscapeLayoutImpressions) + } + } + + return nil + } + /// Opens Duck Player information modal. + /// + /// - Parameters: + /// - params: Parameters from the web content. + /// - message: The script message containing the parameters. @MainActor public func openDuckPlayerInfo(params: Any, message: WKScriptMessage) async -> Encodable? { guard let body = message.body as? [String: Any], @@ -284,23 +401,18 @@ final class DuckPlayer: DuckPlayerProtocol { return nil } + /// Presents the Duck Player info modal. + /// + /// - Parameter context: The presentation context for the modal. @MainActor - public func telemetryEvent(params: Any, message: WKScriptMessage) async -> Encodable? { - guard let event: TelemetryEvent = DecodableHelper.decode(from: params) else { - return nil - } - - switch event.attributes { - case .impression(let attrs): - switch attrs.value { - case .landscape: - Pixel.fire(pixel: .duckPlayerLandscapeLayoutImpressions) - } - } - - return nil + public func presentDuckPlayerInfo(context: DuckPlayerModalPresenter.PresentationContext) { + guard let hostView else { return } + DuckPlayerModalPresenter(context: context).presentDuckPlayerFeatureModal(on: hostView) } - + + /// Encodes user values for sending to the web content. + /// + /// - Returns: An instance of `UserValues`. private func encodeUserValues() -> UserValues { return UserValues( duckPlayerMode: featureFlagger.isFeatureOn(.duckPlayer) ? settings.mode : .disabled, @@ -308,12 +420,19 @@ final class DuckPlayer: DuckPlayerProtocol { ) } + /// Encodes UI values for sending to the web content. + /// + /// - Returns: An instance of `UIValues`. private func encodeUIValues() -> UIValues { UIValues( allowFirstVideo: settings.allowFirstVideo ) } + /// Prepares and encodes player settings to send to the web content. + /// + /// - Parameter webView: The web view to check for PiP capability. + /// - Returns: An instance of `InitialPlayerSettings`. @MainActor private func encodedPlayerSettings(with webView: WKWebView?) async -> InitialPlayerSettings { let isPiPEnabled = webView?.configuration.allowsPictureInPictureMediaPlayback == true @@ -323,16 +442,22 @@ final class DuckPlayer: DuckPlayerProtocol { let playerSettings = InitialPlayerSettings.PlayerSettings(pip: pip) let userValues = encodeUserValues() let uiValues = encodeUIValues() - let settings = InitialPlayerSettings(userValues: userValues, - ui: uiValues, - settings: playerSettings, - platform: platform, - locale: locale, - localeStrings: localeStrings) + let settings = InitialPlayerSettings( + userValues: userValues, + ui: uiValues, + settings: playerSettings, + platform: platform, + locale: locale, + localeStrings: localeStrings + ) return settings } - // Accessing WKMessage needs main thread + /// Fires analytics pixels based on user interactions. + /// + /// - Parameters: + /// - message: The script message containing the interaction data. + /// - userValues: The user values to determine which pixels to fire. @MainActor private func firePixels(message: WKScriptMessage, userValues: UserValues) { @@ -341,9 +466,35 @@ final class DuckPlayer: DuckPlayerProtocol { return } guard let feature = messageData.featureName else { return } - let event: Pixel.Event = feature == FeatureName.page.rawValue ? .duckPlayerSettingAlwaysDuckPlayer : .duckPlayerSettingAlwaysDuckPlayer - if userValues.duckPlayerMode == .enabled { - Pixel.fire(pixel: event) + + // Get the webView URL + let webView = message.webView + guard let webView = message.webView, let url = webView.url else { + return + } + + // Based on the URL, determine which pixels to fire + let isSERP = url.isDuckDuckGoSearch + + // Assume we are in the SERP Overlay + if isSERP { + switch userValues.duckPlayerMode { + case .enabled: + Pixel.fire(pixel: .duckPlayerSettingsAlwaysOverlaySERP) + case .disabled: + Pixel.fire(pixel: .duckPlayerSettingsNeverOverlaySERP) + default: break + } + + // Assume we are in the Youtube Overlay + } else { + switch userValues.duckPlayerMode { + case .enabled: + Pixel.fire(pixel: .duckPlayerSettingsAlwaysOverlayYoutube) + case .disabled: + Pixel.fire(pixel: .duckPlayerSettingsNeverOverlayYoutube) + default: break + } } } diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerLaunchExperiment.swift b/DuckDuckGo/DuckPlayer/DuckPlayerLaunchExperiment.swift deleted file mode 100644 index be9821fe3e..0000000000 --- a/DuckDuckGo/DuckPlayer/DuckPlayerLaunchExperiment.swift +++ /dev/null @@ -1,239 +0,0 @@ -// -// DuckPlayerLaunchExperiment.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 Core - - -// Date manipulation protocol to allow testing -public protocol DuckPlayerExperimentDateProvider { - var currentDate: Date { get } -} - -public class DefaultDuckPlayerExperimentDateProvider: DuckPlayerExperimentDateProvider { - public var currentDate: Date { - return Date() - } -} - -// Wrap Pixel firing in a protocol for better testing -protocol DuckPlayerExperimentPixelFiring { - static func fireDuckPlayerExperimentPixel(pixel: Pixel.Event, withAdditionalParameters params: [String: String]) -} - -extension Pixel: DuckPlayerExperimentPixelFiring { - static func fireDuckPlayerExperimentPixel(pixel: Pixel.Event, withAdditionalParameters params: [String: String]) { - self.fire(pixel: pixel, withAdditionalParameters: params, onComplete: { _ in }) - } -} - - -// Experiment Protocol -protocol DuckPlayerLaunchExperimentHandling { - var isEnrolled: Bool { get } - var isExperimentCohort: Bool { get } - var duckPlayerMode: DuckPlayerMode? { get set } - func assignUserToCohort() - func fireSearchPixels() - func fireYoutubePixel(videoID: String) -} - - -final class DuckPlayerLaunchExperiment: DuckPlayerLaunchExperimentHandling { - - private struct Constants { - static let dateFormat = "yyyyMMdd" - static let enrollmentKey = "enrollment" - static let variantKey = "variant" - static let dayKey = "day" - static let weekKey = "week" - static let stateKey = "state" - static let referrerKey = "referrer" - } - - private let referrer: DuckPlayerReferrer? - var duckPlayerMode: DuckPlayerMode? - - // Abstract Pixel firing for proper testing - private let pixel: DuckPlayerExperimentPixelFiring.Type - - // Date Provider - private let dateProvider: DuckPlayerExperimentDateProvider - - @UserDefaultsWrapper(key: .duckPlayerPixelExperimentLastWeekPixelFired, defaultValue: nil) - private var lastWeekPixelFiredV2: Int? - - @UserDefaultsWrapper(key: .duckPlayerPixelExperimentLastDayPixelFired, defaultValue: nil) - private var lastDayPixelFiredV2: Int? - - @UserDefaultsWrapper(key: .duckPlayerPixelExperimentLastVideoIDRendered, defaultValue: nil) - private var lastVideoIDReportedV2: String? - - @UserDefaultsWrapper(key: .duckPlayerPixelExperimentEnrollmentDate, defaultValue: nil) - var enrollmentDateV2: Date? - - @UserDefaultsWrapper(key: .duckPlayerPixelExperimentCohort, defaultValue: nil) - var experimentCohortV2: String? - - private var isInternalUser: Bool - - enum Cohort: String { - case control - case experiment - } - - init(duckPlayerMode: DuckPlayerMode? = nil, - referrer: DuckPlayerReferrer? = nil, - userDefaults: UserDefaults = UserDefaults.standard, - pixel: DuckPlayerExperimentPixelFiring.Type = Pixel.self, - dateProvider: DuckPlayerExperimentDateProvider = DefaultDuckPlayerExperimentDateProvider(), - isInternalUser: Bool = false) { - self.referrer = referrer - self.duckPlayerMode = duckPlayerMode - self.pixel = pixel - self.dateProvider = dateProvider - self.isInternalUser = isInternalUser - } - - private var dates: (day: Int, week: Int)? { - guard isEnrolled, - let enrollmentDate = enrollmentDateV2 else { return nil } - let currentDate = dateProvider.currentDate - let calendar = Calendar.current - let dayDifference = calendar.dateComponents([.day], from: enrollmentDate, to: currentDate).day ?? 0 - let weekDifference = (dayDifference / 7) + 1 - return (day: dayDifference, week: weekDifference) - } - - private var formattedEnrollmentDate: String? { - guard isEnrolled, - let enrollmentDate = enrollmentDateV2 else { return nil } - return Self.formattedDate(enrollmentDate) - } - - static func formattedDate(_ date: Date) -> String { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = Constants.dateFormat - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) - return dateFormatter.string(from: date) - } - - var isEnrolled: Bool { - return enrollmentDateV2 != nil && experimentCohortV2 != nil - } - - var isExperimentCohort: Bool { - return experimentCohortV2 == "experiment" - } - - func assignUserToCohort() { - if !isEnrolled { - var cohort: Cohort = Bool.random() ? .experiment : .control - - if isInternalUser { - cohort = .experiment - } - experimentCohortV2 = cohort.rawValue - enrollmentDateV2 = dateProvider.currentDate - fireEnrollmentPixel() - } - } - - private func fireEnrollmentPixel() { - guard isEnrolled, - let experimentCohortV2 = experimentCohortV2, - let formattedEnrollmentDate else { return } - - let params = [Constants.variantKey: experimentCohortV2, Constants.enrollmentKey: formattedEnrollmentDate] - pixel.fireDuckPlayerExperimentPixel(pixel: .duckplayerExperimentCohortAssign, withAdditionalParameters: params) - } - - func fireSearchPixels() { - if isEnrolled { - guard isEnrolled, - let experimentCohortV2 = experimentCohortV2, - let dates, - let formattedEnrollmentDate else { - return - } - - var params = [ - Constants.variantKey: experimentCohortV2, - Constants.dayKey: "\(dates.day)", - Constants.enrollmentKey: formattedEnrollmentDate - ] - - // Fire a base search pixel - pixel.fireDuckPlayerExperimentPixel(pixel: .duckplayerExperimentSearch, withAdditionalParameters: params) - - // Fire a daily pixel - if dates.day != lastDayPixelFiredV2 { - pixel.fireDuckPlayerExperimentPixel(pixel: .duckplayerExperimentDailySearch, withAdditionalParameters: params) - lastDayPixelFiredV2 = dates.day - } - - // Fire a weekly pixel - if dates.week != lastWeekPixelFiredV2 && dates.day > 0 { - params.removeValue(forKey: Constants.dayKey) - params[Constants.weekKey] = "\(dates.week)" - pixel.fireDuckPlayerExperimentPixel(pixel: .duckplayerExperimentWeeklySearch, withAdditionalParameters: params) - lastWeekPixelFiredV2 = dates.week - } - } - } - - func fireYoutubePixel(videoID: String) { - guard isEnrolled, - let experimentCohortV2 = experimentCohortV2, - let dates, - let formattedEnrollmentDate else { - return - } - - let params = [ - Constants.variantKey: experimentCohortV2, - Constants.dayKey: "\(dates.day)", - Constants.stateKey: duckPlayerMode?.stringValue ?? "", - Constants.referrerKey: referrer?.stringValue ?? "", - Constants.enrollmentKey: formattedEnrollmentDate - ] - if lastVideoIDReportedV2 != videoID { - pixel.fireDuckPlayerExperimentPixel(pixel: .duckplayerExperimentYoutubePageView, withAdditionalParameters: params) - lastVideoIDReportedV2 = videoID - } - } - - func cleanup() { - enrollmentDateV2 = nil - experimentCohortV2 = nil - lastDayPixelFiredV2 = nil - lastWeekPixelFiredV2 = nil - lastVideoIDReportedV2 = nil - } - - func override(control: Bool = false) { - enrollmentDateV2 = Date() - experimentCohortV2 = control ? "control" : "experiment" - lastDayPixelFiredV2 = nil - lastWeekPixelFiredV2 = nil - lastVideoIDReportedV2 = nil - - } - -} diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift index 16acb20fd1..adb9d58ab0 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift @@ -26,16 +26,51 @@ import BrowserServicesKit import DuckPlayer import os.log -final class DuckPlayerNavigationHandler { +/// Handles navigation and interactions related to Duck Player within the app. +final class DuckPlayerNavigationHandler: NSObject { + + /// The DuckPlayer instance used for handling video playback. + var duckPlayer: DuckPlayerControlling - var duckPlayer: DuckPlayerProtocol + /// Indicates where the DuckPlayer was referred from (e.g., YouTube, SERP). var referrer: DuckPlayerReferrer = .other - var lastHandledVideoID: String? + + /// Feature flag manager for enabling/disabling features. var featureFlagger: FeatureFlagger + + /// Application settings. var appSettings: AppSettings - var navigationType: WKNavigationType = .other - var experiment: DuckPlayerLaunchExperimentHandling - private lazy var internalUserDecider = AppDependencyProvider.shared.internalUserDecider + + /// Pixel firing utility for analytics. + var pixelFiring: PixelFiring.Type + let dailyPixelFiring: DailyPixelFiring.Type + + /// Keeps track of the last YouTube video watched. + var lastWatchInYoutubeVideo: String? + + // Redirection Throttle + /// Timestamp of the last Duck Player redirection. + private var lastDuckPlayerRedirect: Date? + + /// Duration to throttle Duck Player redirects. + private let lastDuckPlayerRedirectThrottleDuration: TimeInterval = 1 + + // Navigation URL Changing Throttle + /// Timestamp of the last URL change handling. + private var lastURLChangeHandling: Date? + + /// Duration to throttle URL change handling. + private let lastURLChangeHandlingThrottleDuration: TimeInterval = 1 + + // Navigation Cancelling Throttle + /// Timestamp of the last navigation handling. + private var lastNavigationHandling: Date? + + /// Duration to throttle navigation handling. + private let lastNavigationHandlingThrottleDuration: TimeInterval = 1 + + /// Delegate for handling tab navigation events. + weak var tabNavigationHandler: DuckPlayerTabNavigationHandling? private struct Constants { static let SERPURL = "duckduckgo.com/" @@ -50,21 +85,44 @@ final class DuckPlayerNavigationHandler { static let httpMethod = "GET" static let watchInYoutubePath = "openInYoutube" static let watchInYoutubeVideoParameter = "v" - static let urlInternalReferrer = "embeds_referring_euri" + static let youtubeEmbedURI = "embeds_referring_euri" static let youtubeScheme = "youtube://" static let duckPlayerScheme = URL.NavigationalScheme.duck.rawValue + static let duckPlayerReferrerParameter = "dp_referrer" + static let newTabParameter = "dp_isNewTab" + static let allowFirstVideoParameter = "dp_allowFirstVideo" + } + + private struct DuckPlayerParameters { + let referrer: DuckPlayerReferrer + let isNewTap: Bool + let allowFirstVideo: Bool } - init(duckPlayer: DuckPlayerProtocol = DuckPlayer(), + /// Initializes a new instance of `DuckPlayerNavigationHandler` with the provided dependencies. + /// + /// - Parameters: + /// - duckPlayer: The DuckPlayer instance. + /// - featureFlagger: The feature flag manager. + /// - appSettings: The application settings. + /// - pixelFiring: The pixel firing utility for analytics. + /// - dailyPixelFiring: The daily pixel firing utility for analytics. + /// - tabNavigationHandler: The tab navigation handler delegate. + init(duckPlayer: DuckPlayerControlling = DuckPlayer(), featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger, appSettings: AppSettings, - experiment: DuckPlayerLaunchExperimentHandling = DuckPlayerLaunchExperiment()) { + pixelFiring: PixelFiring.Type = Pixel.self, + dailyPixelFiring: DailyPixelFiring.Type = DailyPixel.self, + tabNavigationHandler: DuckPlayerTabNavigationHandling? = nil) { self.duckPlayer = duckPlayer self.featureFlagger = featureFlagger self.appSettings = appSettings - self.experiment = experiment + self.pixelFiring = pixelFiring + self.dailyPixelFiring = dailyPixelFiring + self.tabNavigationHandler = tabNavigationHandler } + /// Returns the file path for the Duck Player HTML template. static var htmlTemplatePath: String { guard let file = ContentScopeScripts.Bundle.path(forResource: Constants.templateName, ofType: Constants.templateExtension, @@ -75,6 +133,10 @@ final class DuckPlayerNavigationHandler { return file } + /// Creates a `URLRequest` for Duck Player using the original request's YouTube video ID and timestamp. + /// + /// - Parameter originalRequest: The original YouTube `URLRequest`. + /// - Returns: A new `URLRequest` pointing to the Duck Player. static func makeDuckPlayerRequest(from originalRequest: URLRequest) -> URLRequest { guard let (youtubeVideoID, timestamp) = originalRequest.url?.youtubeVideoParams else { assertionFailure("Request should have ID") @@ -83,6 +145,12 @@ final class DuckPlayerNavigationHandler { return makeDuckPlayerRequest(for: youtubeVideoID, timestamp: timestamp) } + /// Generates a `URLRequest` for Duck Player with a specific YouTube video ID and optional timestamp. + /// + /// - Parameters: + /// - videoID: The YouTube video ID. + /// - timestamp: Optional timestamp for the video. + /// - Returns: A `URLRequest` configured for Duck Player. static func makeDuckPlayerRequest(for videoID: String, timestamp: String?) -> URLRequest { var request = URLRequest(url: .youtubeNoCookie(videoID, timestamp: timestamp)) request.addValue(Constants.localhost, forHTTPHeaderField: Constants.refererHeader) @@ -90,6 +158,9 @@ final class DuckPlayerNavigationHandler { return request } + /// Loads and returns the HTML content from the Duck Player template file. + /// + /// - Returns: The HTML content as a `String`. static func makeHTMLFromTemplate() -> String { guard let html = try? String(contentsOfFile: htmlTemplatePath) else { assertionFailure("Should be able to load template") @@ -98,72 +169,57 @@ final class DuckPlayerNavigationHandler { return html } + /// Navigates to the Duck Player URL in the web view. Opens in a new tab if settings dictate. + /// + /// - Parameters: + /// - request: The `URLRequest` to navigate to. + /// - responseHTML: The HTML content to load. + /// - webView: The `WKWebView` to load the content into. + @MainActor private func performNavigation(_ request: URLRequest, responseHTML: String, webView: WKWebView) { - webView.loadSimulatedRequest(request, responseHTML: responseHTML) + + // If DuckPlayer is enabled, and we're watching a video in YouTube (temporarily) + // Any direct navigation to a duck:// URL should open in a new tab + if let url = webView.url, url.isYoutubeWatch && isOpenInNewTabEnabled && duckPlayerMode == .enabled { + self.redirectToDuckPlayerVideo(url: request.url, webView: webView) + return + } + // Otherwise, just load the simulated request + // New tabs require a short interval so the Omnibars dismissal propagates + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + webView.loadSimulatedRequest(request, responseHTML: responseHTML) + } } + /// Handles the Duck Player request by generating HTML from the template and performing navigation. + /// + /// - Parameters: + /// - request: The `URLRequest` to handle. + /// - webView: The `WKWebView` to load the content into. + @MainActor private func performRequest(request: URLRequest, webView: WKWebView) { let html = Self.makeHTMLFromTemplate() let duckPlayerRequest = Self.makeDuckPlayerRequest(from: request) performNavigation(duckPlayerRequest, responseHTML: html, webView: webView) } - private var duckPlayerMode: DuckPlayerMode { - let isEnabled = experiment.isEnrolled && experiment.isExperimentCohort && featureFlagger.isFeatureOn(.duckPlayer) - return isEnabled ? duckPlayer.settings.mode : .disabled + /// Checks if the Duck Player feature is enabled via feature flags. + private var isDuckPlayerFeatureEnabled: Bool { + featureFlagger.isFeatureOn(.duckPlayer) } - // Handle URL changes not triggered via Omnibar - // such as changes triggered via JS - @MainActor - private func handleURLChange(url: URL?, webView: WKWebView) { - - guard let url else { return } - - guard featureFlagger.isFeatureOn(.duckPlayer) else { - return - } - - // This is passed to the FE overlay at init to disable the overlay for one video - duckPlayer.settings.allowFirstVideo = false - - if let (videoID, _) = url.youtubeVideoParams, - videoID == lastHandledVideoID { - Logger.duckPlayer.debug("URL (\(url.absoluteString) already handled, skipping") - return - } - - // Handle Youtube internal links like "Age restricted" and "Copyright restricted" videos - // These should not be handled by DuckPlayer - if url.isYoutubeVideo, - url.hasWatchInYoutubeQueryParameter { - duckPlayer.settings.allowFirstVideo = true - return - } - - if url.isYoutubeVideo, - !url.isDuckPlayer, - let (videoID, timestamp) = url.youtubeVideoParams, - duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk { - - Logger.duckPlayer.debug("Handling URL change: \(url.absoluteString)") - webView.load(URLRequest(url: URL.duckPlayer(videoID, timestamp: timestamp))) - lastHandledVideoID = videoID - } + /// Determines if "Open in New Tab" for Duck Player is enabled in the settings. + private var isOpenInNewTabEnabled: Bool { + featureFlagger.isFeatureOn(.duckPlayer) && featureFlagger.isFeatureOn(.duckPlayerOpenInNewTab) && duckPlayer.settings.openInNewTab && duckPlayerMode != .disabled } - // Get the duck:// URL youtube-no-cookie URL - func getDuckURLFor(_ url: URL) -> URL { - guard let (youtubeVideoID, timestamp) = url.youtubeVideoParams, - url.isDuckPlayer, - !url.isDuckURLScheme, - duckPlayerMode != .disabled - else { - return url - } - return URL.duckPlayer(youtubeVideoID, timestamp: timestamp) + /// Retrieves the current mode of Duck Player based on feature flags and user settings. + private var duckPlayerMode: DuckPlayerMode { + let isEnabled = isDuckPlayerFeatureEnabled + return isEnabled ? duckPlayer.settings.mode : .disabled } + /// Checks if the YouTube app is installed on the device. private var isYouTubeAppInstalled: Bool { if let youtubeURL = URL(string: Constants.youtubeScheme) { return UIApplication.shared.canOpenURL(youtubeURL) @@ -171,31 +227,24 @@ final class DuckPlayerNavigationHandler { return false } - private func isSERPLink(navigationAction: WKNavigationAction) -> Bool { - guard let referrer = navigationAction.request.allHTTPHeaderFields?[Constants.refererHeader] else { - return false - } - if referrer.contains(Constants.SERPURL) { - return true - } - return false - } - - private func isOpenInYoutubeURL(url: URL) -> Bool { - return isWatchInYouTubeURL(url: url) - } - + /// Extracts a YouTube URL from a Duck Player "Open in YouTube" link. + /// + /// - Parameter url: The URL to parse. + /// - Returns: A YouTube `URL` if available. private func getYoutubeURLFromOpenInYoutubeLink(url: URL) -> URL? { guard isWatchInYouTubeURL(url: url), let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), let videoParameterItem = urlComponents.queryItems?.first(where: { $0.name == Constants.watchInYoutubeVideoParameter }), - let id = videoParameterItem.value, - let newURL = URL.youtube(id, timestamp: nil).addingWatchInYoutubeQueryParameter() else { + let id = videoParameterItem.value else { return nil } - return newURL + return URL.youtube(id, timestamp: nil) } + /// Determines if the URL is an "Open in YouTube" Duck Player link. + /// + /// - Parameter url: The URL to check. + /// - Returns: `true` if it's an "Open in YouTube" link, `false` otherwise. private func isWatchInYouTubeURL(url: URL) -> Bool { guard url.scheme == Constants.duckPlayerScheme, let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), @@ -205,368 +254,678 @@ final class DuckPlayerNavigationHandler { return true } - // DuckPlayer Experiment Handling - private func handleYouTubePageVisited(url: URL?, navigationAction: WKNavigationAction?) { - guard let url else { return } + /// Redirects the web view to play the video in Duck Player, optionally forcing a new tab. + /// + /// - Parameters: + /// - url: The URL of the video. + /// - webView: The `WKWebView` to load the content into. + /// - forceNewTab: Whether to force opening in a new tab. + /// - disableNewTab: Ignore openInNewTab settings + @MainActor + private func redirectToDuckPlayerVideo(url: URL?, webView: WKWebView, forceNewTab: Bool = false, disableNewTab: Bool = false) { - // Parse openInYoutubeURL if present - let newURL = getYoutubeURLFromOpenInYoutubeLink(url: url) ?? url + guard let url, + let (videoID, _) = url.youtubeVideoParams else { return } - guard let (videoID, _) = newURL.youtubeVideoParams else { return } + let duckPlayerURL = URL.duckPlayer(videoID) + self.loadWithDuckPlayerParameters(URLRequest(url: duckPlayerURL), referrer: self.referrer, webView: webView, forceNewTab: forceNewTab, disableNewTab: disableNewTab) + } + + /// Redirects to the YouTube video page, allowing the first video if necessary. + /// + /// - Parameters: + /// - url: The URL of the video. + /// - webView: The `WKWebView` to load the content into. + /// - forceNewTab: Whether to force opening in a new tab. + /// - allowFirstVideo: Hide DuckPlayer Overlay in the first loaded video + /// - disableNewTab: Ignore openInNewTab settings + @MainActor + private func redirectToYouTubeVideo(url: URL?, webView: WKWebView, forceNewTab: Bool = false, allowFirstVideo: Bool = true, disableNewTab: Bool = false) { - // If this is a SERP link, set the referrer accordingly - if let navigationAction, isSERPLink(navigationAction: navigationAction) { - referrer = .serp + guard let url else { return } + + var redirectURL = url + + // Parse OpenInYouTubeURLs if present + if let parsedURL = getYoutubeURLFromOpenInYoutubeLink(url: url) { + redirectURL = parsedURL } + + // When redirecting to YouTube, we always allow the first video + loadWithDuckPlayerParameters(URLRequest(url: redirectURL), referrer: referrer, webView: webView, forceNewTab: forceNewTab, allowFirstVideo: allowFirstVideo, disableNewTab: disableNewTab) + } + + + /// Fires analytics pixels when Duck Player is viewed, based on referrer and settings. + private func fireDuckPlayerPixels(webView: WKWebView) { - if featureFlagger.isFeatureOn(.duckPlayer) || internalUserDecider.isInternalUser { - - // DuckPlayer Experiment run - let experiment = DuckPlayerLaunchExperiment(duckPlayerMode: duckPlayerMode, - referrer: referrer, - isInternalUser: internalUserDecider.isInternalUser) - - // Enroll user if not enrolled - if !experiment.isEnrolled { - experiment.assignUserToCohort() - - // DuckPlayer is disabled before user enrolls, - // So trigger a settings change notification - // to let the FE know about the 'actual' setting - // and update Experiment value - if experiment.isExperimentCohort { - duckPlayer.settings.triggerNotification() - experiment.duckPlayerMode = duckPlayer.settings.mode + // First daily unique user Duck Player view + dailyPixelFiring.fireDaily(.duckPlayerDailyUniqueView, withAdditionalParameters: ["settings": duckPlayerMode.stringValue]) + + // Duck Player viewed with Always setting, referred from YouTube (automatic) + if (referrer == .youtube) && duckPlayerMode == .enabled { + pixelFiring.fire(.duckPlayerViewFromYoutubeAutomatic, withAdditionalParameters: [:]) + } + + // Duck Player viewed from SERP + if referrer == .serp { + pixelFiring.fire(.duckPlayerViewFromSERP, withAdditionalParameters: [:]) + } + + // Other referrers + if referrer == .other || referrer == .undefined { + pixelFiring.fire(.duckPlayerViewFromOther, withAdditionalParameters: [:]) + } + + } + + /// Fires an analytics pixel when the user opts to watch a video on YouTube instead. + private func fireOpenInYoutubePixel() { + pixelFiring.fire(.duckPlayerWatchOnYoutube, withAdditionalParameters: [:]) + } + + /// Cancels JavaScript-triggered navigation by stopping the load and going back if possible. + /// + /// - Parameters: + /// - webView: The `WKWebView` to manipulate. + /// - completion: Optional completion handler. + @MainActor + private func cancelJavascriptNavigation(webView: WKWebView, completion: (() -> Void)? = nil) { + + if duckPlayerMode == .enabled { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + webView.stopLoading() + if webView.canGoBack { + webView.goBack() } + completion?() + } + } else { + completion?() + } + + } + + /// Loads a request with Duck Player parameters, handling new tab logic and first video allowance. + /// + /// - Parameters: + /// - request: The `URLRequest` to load. + /// - referrer: The referrer information. + /// - webView: The `WKWebView` to load the content into. + /// - forceNewTab: Whether to force opening in hana new tab. + /// - allowFirstVideo: Whether to allow the first video to play. + /// - disableNewTab: Ignores Open in New tab settings + private func loadWithDuckPlayerParameters(_ request: URLRequest, + referrer: DuckPlayerReferrer, + webView: WKWebView, + forceNewTab: Bool = false, + allowFirstVideo: Bool = false, + disableNewTab: Bool = false) { + + guard let url = request.url else { + return + } + + // We want to prevent multiple simultaneous redirects + // This can be caused by Duplicate Nav events, and YouTube's own redirects + if let lastTimestamp = lastDuckPlayerRedirect { + let timeSinceLastThrottle = Date().timeIntervalSince(lastTimestamp) + if timeSinceLastThrottle < lastDuckPlayerRedirectThrottleDuration { + return } + } + lastDuckPlayerRedirect = Date() + + // Remove any DP Parameters + guard let strippedURL = removeDuckPlayerParameters(from: url) else { + return + } + + // Set allowFirstVideo + duckPlayer.settings.allowFirstVideo = allowFirstVideo + + // Get parameter values + let isNewTab = (isOpenInNewTabEnabled && duckPlayerMode == .enabled) || forceNewTab ? "1" : "0" + let allowFirstVideo = allowFirstVideo ? "1" : "0" + let referrer = referrer.rawValue + + var newURL = strippedURL + var urlComponents = URLComponents(url: strippedURL, resolvingAgainstBaseURL: false) + var queryItems = urlComponents?.queryItems ?? [] - experiment.fireYoutubePixel(videoID: videoID) + // Append DuckPlayer parameters + queryItems.append(URLQueryItem(name: Constants.newTabParameter, value: isNewTab)) + queryItems.append(URLQueryItem(name: Constants.duckPlayerReferrerParameter, value: referrer)) + queryItems.append(URLQueryItem(name: Constants.allowFirstVideoParameter, value: allowFirstVideo)) + urlComponents?.queryItems = queryItems + + // Create a new request with the modified URL + newURL = urlComponents?.url ?? newURL + + // Only Open in new tab if enabled + if (isOpenInNewTabEnabled || forceNewTab) && !disableNewTab { + tabNavigationHandler?.openTab(for: newURL) + } else { + webView.load(URLRequest(url: newURL)) } - + + Logger.duckPlayer.debug("DP: loadWithDuckPlayerParameters: \(newURL.absoluteString)") + + } + + /// Extracts Duck Player-specific parameters from the URL for internal use. + /// + /// - Parameter url: The URL to parse. + /// - Returns: A `DuckPlayerParameters` struct containing the extracted values. + private func getDuckPlayerParameters(url: URL) -> DuckPlayerParameters { + + guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = urlComponents.queryItems else { + return DuckPlayerParameters(referrer: .other, isNewTap: false, allowFirstVideo: false) + } + + let referrerValue = queryItems.first(where: { $0.name == Constants.duckPlayerReferrerParameter })?.value + let allowFirstVideoValue = queryItems.first(where: { $0.name == Constants.allowFirstVideoParameter })?.value + let isNewTabValue = queryItems.first(where: { $0.name == Constants.newTabParameter })?.value + let youtubeEmbedURI = queryItems.first(where: { $0.name == Constants.youtubeEmbedURI })?.value + + // Use the from(string:) method to parse referrer + let referrer = DuckPlayerReferrer(string: referrerValue ?? "") + let allowFirstVideo = allowFirstVideoValue == "1" || youtubeEmbedURI.map(\.isEmpty) ?? false + let isNewTab = isNewTabValue == "1" + + return DuckPlayerParameters(referrer: referrer, isNewTap: isNewTab, allowFirstVideo: allowFirstVideo) + } + + /// Removes Duck Player-specific query parameters from a URL. + /// + /// - Parameter url: The URL to clean. + /// - Returns: A new URL without Duck Player parameters. + private func removeDuckPlayerParameters(from url: URL) -> URL? { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return url + } + + let parametersToRemove = [Constants.newTabParameter, + Constants.duckPlayerReferrerParameter] + + // Filter out the parameters you want to remove + components.queryItems = queryItems.filter { !parametersToRemove.contains($0.name) } + + // Return the modified URL + return components.url + } + + /// Determines if a URL is a DuckPlayer redirect based on its parameters + /// + /// - Parameter url: To check + /// - Returns: True | False + private func isDuckPlayerRedirect(url: URL) -> Bool { + + guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = urlComponents.queryItems else { + return false + } + + let referrerValue = queryItems.first(where: { $0.name == Constants.duckPlayerReferrerParameter })?.value + let allowFirstVideoValue = queryItems.first(where: { $0.name == Constants.allowFirstVideoParameter })?.value + let isNewTabValue = queryItems.first(where: { $0.name == Constants.newTabParameter })?.value + let youtubeEmbedURI = queryItems.first(where: { $0.name == Constants.youtubeEmbedURI })?.value + + return referrerValue != nil || allowFirstVideoValue != nil || isNewTabValue != nil || youtubeEmbedURI != nil + } + + /// Sets the referrer based on the current web view URL to aid in analytics. + /// + /// - Parameter webView: The `WKWebView` whose URL is used to determine the referrer. + private func setReferrer(webView: WKWebView) { + + // Make sure we are NOT DuckPlayer + guard let url = webView.url, !url.isDuckPlayer else { return } + + // First, try to use the back Item + var backItems = webView.backForwardList.backList.reversed() + + // Ignore any previous URL that's duckPlayer or youtube-no-cookie + if backItems.first?.url != nil, url.isDuckPlayer { + backItems = webView.backForwardList.backList.dropLast().reversed() + } + + // If the current URL is DuckPlayer, use the previous history item + guard let referrerURL = url.isDuckPlayer ? backItems.first?.url : url else { + return + } + + // SERP as a referrer + if referrerURL.isDuckDuckGoSearch { + referrer = .serp + return + } + + // Set to Youtube for "Watch in Youtube videos" + if referrerURL.isYoutubeWatch && duckPlayerMode == .enabled && duckPlayer.settings.allowFirstVideo { + referrer = .youtube + return + } + + // Set to Overlay for Always ask + if referrerURL.isYoutubeWatch && duckPlayerMode == .alwaysAsk { + referrer = .youtubeOverlay + return + } + + // Any Other Youtube URL or other referrer + if referrerURL.isYoutube { + referrer = .youtube + return + } else { + referrer = .other + } + } - // Determines if the link should be opened in a new tab - // And sets the correct navigationType - // This is uses for JS based navigation links - private func setOpenInNewTab(url: URL?) { - guard let url else { + /// Determines if the current tab is a new tab based on the targetFrame request and other params + /// + /// - Parameter navigationAction: The `WKNavigationAction` used to determine the tab type. + private func isNewTab(_ navigationAction: WKNavigationAction) -> Bool { + + guard let request = navigationAction.targetFrame?.safeRequest, + let url = request.url else { + return false + } + + // Always return false if open in new tab is disabled + guard isOpenInNewTabEnabled else { return false } + + // If the target frame is duckPlayer itself or there's no URL + // we're at a new tab + if url.isDuckPlayer || url.isEmpty { + return true + } + + return false + } + + /// // Handle "open in YouTube" links (duck://player/openInYoutube) + /// + /// - Parameter url: The `URL` used to determine the tab type. + /// - Parameter webView: The `WebView` used for navigation/redirection + @MainActor + private func handleOpenInYoutubeLink(url: URL, webView: WKWebView) { + + // Handle "open in YouTube" links (duck://player/openInYoutube) + guard let (videoID, _) = url.youtubeVideoParams else { return } - // let openInNewTab = appSettings.duckPlayerOpenInNewTab - let openInNewTab = appSettings.duckPlayerOpenInNewTab - let isFeatureEnabled = featureFlagger.isFeatureOn(.duckPlayer) - let isSubFeatureEnabled = featureFlagger.isFeatureOn(.duckPlayerOpenInNewTab) || internalUserDecider.isInternalUser - let isDuckPlayerEnabled = duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk + // Fire a Pixel for Open in YouTube + self.fireOpenInYoutubePixel() - if openInNewTab && - isFeatureEnabled && - isSubFeatureEnabled && - isDuckPlayerEnabled { - navigationType = .linkActivated + // Attempt to open in YouTube app or load in webView + if appSettings.allowUniversalLinks, isYouTubeAppInstalled, + let youtubeAppURL = URL(string: "\(Constants.youtubeScheme)\(videoID)") { + UIApplication.shared.open(youtubeAppURL) } else { - navigationType = .other + // Watch in YT videos always open in new tab + redirectToYouTubeVideo(url: url, webView: webView, forceNewTab: true) } + + } } extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling { - - // Handle rendering the simulated request if the URL is duck:// - // and DuckPlayer is either enabled or alwaysAsk + + /// Manages navigation actions to Duck Player URLs, handling redirects and loading as needed. + /// + /// - Parameters: + /// - navigationAction: The `WKNavigationAction` to handle. + /// - webView: The `WKWebView` where navigation is occurring. @MainActor - func handleNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) { + func handleDuckNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) { - Logger.duckPlayer.debug("Handling DuckPlayer Player Navigation for \(navigationAction.request.url?.absoluteString ?? "")") - - // This is passed to the FE overlay at init to disable the overlay for one video - duckPlayer.settings.allowFirstVideo = false - - guard let url = navigationAction.request.url else { return } - - guard featureFlagger.isFeatureOn(.duckPlayer) else { return } - - // Handle Youtube internal links like "Age restricted" and "Copyright restricted" videos - // These should not be handled by DuckPlayer - if url.isYoutubeVideo, - url.hasWatchInYoutubeQueryParameter { - return + // We want to prevent multiple simultaneous redirects + // This can be caused by Duplicate Nav events, and quick URL changes + if let lastTimestamp = lastNavigationHandling, + Date().timeIntervalSince(lastTimestamp) < lastNavigationHandlingThrottleDuration { + return } - // Handle Open in Youtube Links - // duck://player/openInYoutube?v=12345 - if let newURL = getYoutubeURLFromOpenInYoutubeLink(url: url) { - - Pixel.fire(pixel: Pixel.Event.duckPlayerWatchOnYoutube) - - // These links should always skip the overlay - duckPlayer.settings.allowFirstVideo = true + lastNavigationHandling = Date() - // Attempt to open in YouTube app (if installed) or load in webView - if appSettings.allowUniversalLinks, - isYouTubeAppInstalled, - let (videoID, _) = newURL.youtubeVideoParams, - let url = URL(string: "\(Constants.youtubeScheme)\(videoID)") { - UIApplication.shared.open(url) - } else { - webView.load(URLRequest(url: newURL)) + guard let url = navigationAction.request.url else { return } + + // Redirect to YouTube if DuckPlayer is disabled + guard duckPlayerMode != .disabled else { + if let (videoID, _) = url.youtubeVideoParams { + redirectToYouTubeVideo(url: URL.youtube(videoID), webView: webView) } return } - - // Daily Unique View Pixel - if url.isDuckPlayer, - duckPlayerMode != .disabled { - let setting = duckPlayerMode == .enabled ? Constants.duckPlayerAlwaysString : Constants.duckPlayerDefaultString - DailyPixel.fire(pixel: Pixel.Event.duckPlayerDailyUniqueView, withAdditionalParameters: [Constants.settingsKey: setting]) + // Handle "open in YouTube" links (duck://player/openInYoutube) + if let openInYouTubeURL = getYoutubeURLFromOpenInYoutubeLink(url: url) { + handleOpenInYoutubeLink(url: openInYouTubeURL, webView: webView) + return } - // Pixel for Views From Youtube - if referrer == .youtube, - duckPlayerMode == .enabled { - Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromYoutubeAutomatic) - } - + // Determine navigation type + let shouldOpenInNewTab = isOpenInNewTabEnabled && !isNewTab(navigationAction) + + // Handle duck:// scheme URLs (Or direct navigation to duck player) if url.isDuckURLScheme { - - // If DuckPlayer is Enabled or in ask mode, render the video - if duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk, + + // If should be opened in a new tab, and it's not a DuckPlayer URL, it means this + // is a direct duck:// navigation, so we need to properly redirect to a duckPlayer version + if shouldOpenInNewTab && !isDuckPlayerRedirect(url: url) { + redirectToDuckPlayerVideo(url: url, webView: webView, forceNewTab: true) + return + } + + // Simulate DuckPlayer request if in enabled/ask mode and not redirected to YouTube + if duckPlayerMode != .disabled, !url.hasWatchInYoutubeQueryParameter { let newRequest = Self.makeDuckPlayerRequest(from: URLRequest(url: url)) - Logger.duckPlayer.debug("DP: Loading Simulated Request for \(navigationAction.request.url?.absoluteString ?? "")") - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // The webView needs some time for state to propagate + // Before performing the simulated request + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { self.performRequest(request: newRequest, webView: webView) + self.fireDuckPlayerPixels(webView: webView) } - - // Otherwise, just redirect to YouTube } else { - if let (videoID, timestamp) = url.youtubeVideoParams { - let youtubeURL = URL.youtube(videoID, timestamp: timestamp) - let request = URLRequest(url: youtubeURL) - webView.load(request) - } + redirectToYouTubeVideo(url: url, webView: webView) } return } - + + // Handle YouTube watch URLs based on DuckPlayer settings + if url.isYoutubeWatch, duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk { + if url.hasWatchInYoutubeQueryParameter { + redirectToYouTubeVideo(url: url, webView: webView) + } else { + redirectToDuckPlayerVideo(url: url, webView: webView, forceNewTab: shouldOpenInNewTab) + } + } } - // DecidePolicyFor handler to redirect relevant requests - // to duck://player + /// Observes URL changes and redirects to Duck Player when appropriate, avoiding duplicate handling. + /// + /// - Parameter webView: The `WKWebView` whose URL has changed. + /// - Returns: A result indicating whether the URL change was handled. @MainActor - func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, - completion: @escaping (WKNavigationActionPolicy) -> Void, - webView: WKWebView) { + func handleURLChange(webView: WKWebView) -> DuckPlayerNavigationHandlerURLChangeResult { - Logger.duckPlayer.debug("Handling DecidePolicyFor for \(navigationAction.request.url?.absoluteString ?? "")") - - // This means navigation originated in user Event - // and not automatic. This is used further to - // determine how navigation is performed (new tab, etc) - // Resets on next attachment - if navigationAction.navigationType == .linkActivated { - self.navigationType = navigationAction.navigationType + // We want to prevent multiple simultaneous redirects + // This can be caused by Duplicate Nav events, and quick URL changes + if let lastTimestamp = lastURLChangeHandling, + Date().timeIntervalSince(lastTimestamp) < lastURLChangeHandlingThrottleDuration { + return .notHandled(.duplicateNavigation) } - guard let url = navigationAction.request.url else { - completion(.cancel) - return - } + // Update the Referrer based on the first URL change detected + setReferrer(webView: webView) - guard featureFlagger.isFeatureOn(.duckPlayer) else { - completion(.allow) - return + // We don't want YouTube redirects happening while default navigation is happening + // This can be caused by Duplicate Nav events, and quick URL changes + if let lastTimestamp = lastNavigationHandling, + Date().timeIntervalSince(lastTimestamp) < lastNavigationHandlingThrottleDuration { + return .notHandled(.duplicateNavigation) } - // This is passed to the FE overlay at init to disable the overlay for one video - duckPlayer.settings.allowFirstVideo = false - - if let (videoID, _) = url.youtubeVideoParams, - videoID == lastHandledVideoID, - !url.hasWatchInYoutubeQueryParameter { - Logger.duckPlayer.debug("DP: DecidePolicy: URL (\(url.absoluteString)) already handled, skipping") - completion(.cancel) - return + // Check if DuckPlayer feature is enabled + guard isDuckPlayerFeatureEnabled else { + return .notHandled(.featureOff) } - // Handle Youtube internal links like "Age restricted" and "Copyright restricted" videos - // These should not be handled by DuckPlayer and not include overlays - if url.isYoutubeVideo, - url.hasWatchInYoutubeQueryParameter { - duckPlayer.settings.allowFirstVideo = true - completion(.allow) - return - } - - // SERP referals - if isSERPLink(navigationAction: navigationAction) { - // Set the referer - referrer = .serp - - if duckPlayerMode == .enabled, !url.isDuckPlayer { - Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromSERP, debounce: 2) - } - - } else { - Pixel.fire(pixel: Pixel.Event.duckPlayerViewFromOther, debounce: 2) + guard let url = webView.url, let (videoID, _) = url.youtubeVideoParams else { + return .notHandled(.invalidURL) } - - if url.isYoutubeVideo, - !url.isDuckPlayer, - duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk { - Logger.duckPlayer.debug("DP: Handling decidePolicy for Duck Player with \(url.absoluteString)") - completion(.cancel) - handleURLChange(url: url, webView: webView) - return + guard url.isYoutubeWatch else { + return .notHandled(.isNotYoutubeWatch) } - completion(.allow) - } - - @MainActor - func handleJSNavigation(url: URL?, webView: WKWebView) { + guard videoID != lastWatchInYoutubeVideo else { + lastURLChangeHandling = Date() + return .handled + } - Logger.duckPlayer.debug("Handling JS Navigation for \(url?.absoluteString ?? "")") + let parameters = getDuckPlayerParameters(url: url) - guard featureFlagger.isFeatureOn(.duckPlayer) else { - return + // If the URL has the allow first video, we just don't handle it + if parameters.allowFirstVideo { + lastWatchInYoutubeVideo = videoID + lastURLChangeHandling = Date() + return .handled } - // Assume JS Navigation is user-triggered - self.navigationType = .linkActivated - - // Only handle URL changes if the allowFirstVideo is set to false - // This prevents Youtube redirects from triggering DuckPlayer when is not expected - if !duckPlayer.settings.allowFirstVideo { - handleURLChange(url: url, webView: webView) + guard duckPlayerMode == .enabled else { + return .notHandled(.duckPlayerDisabled) } + + // Handle YouTube watch URLs based on DuckPlayer settings + if duckPlayerMode == .enabled && !parameters.allowFirstVideo { + cancelJavascriptNavigation(webView: webView, completion: { + self.redirectToDuckPlayerVideo(url: url, webView: webView) + }) + lastURLChangeHandling = Date() + Logger.duckPlayer.debug("Handling URL change for \(webView.url?.absoluteString ?? "")") + return .handled + } else { + + } + + return .notHandled(.isNotYoutubeWatch) } + /// Custom back navigation logic to handle Duck Player in the web view's history stack. + /// + /// - Parameter webView: The `WKWebView` to navigate back in. @MainActor func handleGoBack(webView: WKWebView) { - - Logger.duckPlayer.debug("DP: Handling Back Navigation") - - let experiment = DuckPlayerLaunchExperiment() - let duckPlayerMode = experiment.isExperimentCohort ? duckPlayerMode : .disabled - - guard featureFlagger.isFeatureOn(.duckPlayer) else { + + guard isDuckPlayerFeatureEnabled else { webView.goBack() return } - lastHandledVideoID = nil - webView.stopLoading() - - // Check if the back list has items + // Check if the back list has items, and if not try to close the tab guard !webView.backForwardList.backList.isEmpty else { - webView.goBack() + tabNavigationHandler?.closeTab() return } - + // Find the last non-YouTube video URL in the back list - // and navigate to it let backList = webView.backForwardList.backList var nonYoutubeItem: WKBackForwardListItem? - + for item in backList.reversed() where !item.url.isYoutubeVideo && !item.url.isDuckPlayer { nonYoutubeItem = item break } - + if let nonYoutubeItem = nonYoutubeItem, duckPlayerMode == .enabled { - Logger.duckPlayer.debug("DP: Navigating back to \(nonYoutubeItem.url.absoluteString)") + // Delay stopping the loading to avoid interference with go(to:) + webView.stopLoading() webView.go(to: nonYoutubeItem) } else { - Logger.duckPlayer.debug("DP: Navigating back to previous page") + webView.stopLoading() webView.goBack() } } + - // Handle Reload for DuckPlayer Videos + /// Handles reload actions, ensuring Duck Player settings are respected during the reload. + /// + /// - Parameter webView: The `WKWebView` to reload. @MainActor func handleReload(webView: WKWebView) { - Logger.duckPlayer.debug("DP: Handling Reload") - - guard featureFlagger.isFeatureOn(.duckPlayer) else { + // Reset DuckPlayer status + duckPlayer.settings.allowFirstVideo = false + + guard isDuckPlayerFeatureEnabled else { webView.reload() return } - lastHandledVideoID = nil - webView.stopLoading() - if let url = webView.url, url.isDuckPlayer, - !url.isDuckURLScheme, - let (videoID, timestamp) = url.youtubeVideoParams, - duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk { - Logger.duckPlayer.debug("DP: Handling DuckPlayer Reload for \(url.absoluteString)") - webView.load(URLRequest(url: .duckPlayer(videoID, timestamp: timestamp))) - } else { - webView.reload() + guard let url = webView.url else { + return + } + + if url.isDuckPlayer, duckPlayerMode != .disabled { + redirectToDuckPlayerVideo(url: url, webView: webView, disableNewTab: true) + return + } + + if url.isYoutubeWatch, duckPlayerMode == .alwaysAsk { + redirectToYouTubeVideo(url: url, webView: webView, allowFirstVideo: false, disableNewTab: true) + return } + + webView.reload() + } + /// Initializes settings and potentially redirects when the handler is attached to a web view. + /// + /// - Parameter webView: The `WKWebView` being attached. @MainActor func handleAttach(webView: WKWebView) { - Logger.duckPlayer.debug("DP: Attach WebView") + // Reset referrer and initial settings + referrer = .other - guard featureFlagger.isFeatureOn(.duckPlayer) else { + // Ensure feature and mode are enabled + guard isDuckPlayerFeatureEnabled, + let url = webView.url, + duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk else { return } - if let url = webView.url, url.isDuckPlayer, - !url.isDuckURLScheme, - duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk { - Logger.duckPlayer.debug("DP: Handling Initial Load of a video for \(url.absoluteString)") - handleReload(webView: webView) + // Get parameters and determine redirection + let parameters = getDuckPlayerParameters(url: url) + if parameters.allowFirstVideo { + redirectToYouTubeVideo(url: url, webView: webView) + } else { + referrer = parameters.referrer + redirectToDuckPlayerVideo(url: url, webView: webView, disableNewTab: true) } + } + + /// Updates the referrer after the web view finishes loading a page. + /// + /// - Parameter webView: The `WKWebView` that finished loading. + @MainActor + func handleDidFinishLoading(webView: WKWebView) { + + // Reset allowFirstVideo + duckPlayer.settings.allowFirstVideo = false } - // Handle custom events - // This method is used to delegate tasks to DuckPlayerHandler, such as firing pixels and etc. - func handleEvent(event: DuckPlayerNavigationEvent, url: URL?, navigationAction: WKNavigationAction?) { - switch event { - case .youtubeVideoPageVisited: - handleYouTubePageVisited(url: url, navigationAction: navigationAction) - case .JSTriggeredNavigation: - setOpenInNewTab(url: url) + /// Resets settings when the web view starts loading a new page. + /// + /// - Parameter webView: The `WKWebView` that started loading. + @MainActor + func handleDidStartLoading(webView: WKWebView) { + + setReferrer(webView: webView) + + // Automatically reset allowFirstVideo after loading starts + // This is a fallback as the WKNavigation Delegate does not + // Always fires finishLoading (For JS Navigation) which + // triggers handleDidFinishLoading + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.duckPlayer.settings.allowFirstVideo = false } + } - // Determine if the links should be open in a new tab, based on the navigationAction and User setting - // This is used for manually activated links - func shouldOpenInNewTab(_ navigationAction: WKNavigationAction, webView: WKWebView) -> Bool { + /// Converts a standard YouTube URL to its Duck Player equivalent if applicable. + /// + /// - Parameter url: The YouTube `URL` to convert. + /// - Returns: A Duck Player `URL` if applicable. + func getDuckURLFor(_ url: URL) -> URL { + guard let (youtubeVideoID, timestamp) = url.youtubeVideoParams, + url.isDuckPlayer, + !url.isDuckURLScheme, + duckPlayerMode != .disabled + else { + return url + } + return URL.duckPlayer(youtubeVideoID, timestamp: timestamp) + } + + /// Decides whether to cancel navigation to prevent opening the YouTube app from the web view. + /// + /// - Parameters: + /// - navigationAction: The `WKNavigationAction` to evaluate. + /// - webView: The `WKWebView` where navigation is occurring. + /// - Returns: `true` if the navigation should be canceled, `false` otherwise. + @MainActor + func handleDelegateNavigation(navigationAction: WKNavigationAction, webView: WKWebView) -> Bool { - // let openInNewTab = appSettings.duckPlayerOpenInNewTab - let openInNewTab = appSettings.duckPlayerOpenInNewTab - let isFeatureEnabled = featureFlagger.isFeatureOn(.duckPlayer) - let isSubFeatureEnabled = featureFlagger.isFeatureOn(.duckPlayerOpenInNewTab) || internalUserDecider.isInternalUser - let isDuckPlayer = navigationAction.request.url?.isDuckPlayer ?? false - let isDuckPlayerEnabled = duckPlayer.settings.mode == .enabled || duckPlayer.settings.mode == .alwaysAsk + guard let url = navigationAction.request.url else { + return false + } - if openInNewTab && - isFeatureEnabled && - isSubFeatureEnabled && - isDuckPlayer && - self.navigationType == .linkActivated && - isDuckPlayerEnabled { + // Only account for MainFrame navigation + guard navigationAction.isTargetingMainFrame() else { + return false + } + + // Only if DuckPlayer is enabled + guard isDuckPlayerFeatureEnabled else { + return false + } + + // Only account for in 'Always' mode + if duckPlayerMode == .disabled { + return false + } + + // Only account for in 'Duck Player' URL + if url.isDuckPlayer { + return false + } + + // Do not intercept any back/forward navigation + if navigationAction.navigationType == .backForward { + return false + } + + // Ignore YouTube Watch URLs if allowFirst video is set + if url.isYoutubeWatch && duckPlayer.settings.allowFirstVideo { + return false + } + + // Redirect to Duck Player if enabled + if url.isYoutubeWatch && duckPlayerMode == .enabled && !isDuckPlayerRedirect(url: url) { + redirectToDuckPlayerVideo(url: url, webView: webView) return true } + + // Redirect to Youtube + DuckPlayer Overlay if Ask Mode + if url.isYoutubeWatch && duckPlayerMode == .alwaysAsk && !isDuckPlayerRedirect(url: url) { + redirectToYouTubeVideo(url: url, webView: webView, allowFirstVideo: false) + return true + } + + // Allow everything else return false + } } extension WKWebView { - var isEmptyTab: Bool { - return self.url == nil || self.url?.absoluteString == "about:blank" + /// Returns the count of items in the web view's back navigation list. + @objc func backListItemsCount() -> Int { + return backForwardList.backList.count } + } diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift index 2c9ce16bd0..1755a54cf2 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift @@ -19,26 +19,136 @@ import WebKit -enum DuckPlayerNavigationEvent { - case youtubeVideoPageVisited - case JSTriggeredNavigation +/// Represents the referrer source for the Duck Player. +public enum DuckPlayerReferrer: String { + + case youtube + case youtubeOverlay + case serp + case other + case undefined +} + +extension DuckPlayerReferrer { + /// Initializes a `DuckPlayerReferrer` from a string value. + /// + /// - Parameter string: The string representation of the referrer. + init(string: String) { + self = DuckPlayerReferrer(rawValue: string) ?? .undefined + } +} + +/// Represents the result of handling a URL change in the Duck Player navigation handler. +enum DuckPlayerNavigationHandlerURLChangeResult { + + /// Possible reasons for not handling a URL change. + enum HandlingResult { + case featureOff + case invalidURL + case duckPlayerDisabled + case isNotYoutubeWatch + case disabledForVideo + case duplicateNavigation + } + + case handled + case notHandled(HandlingResult) } +/// Represents the direction of navigation in the Duck Player. +enum DuckPlayerNavigationDirection { + case back + case forward +} + +@MainActor +/// Protocol defining the navigation handling for Duck Player. protocol DuckPlayerNavigationHandling: AnyObject { + + /// The referrer of the Duck Player. var referrer: DuckPlayerReferrer { get set } - var duckPlayer: DuckPlayerProtocol { get } - func handleNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) - func handleJSNavigation(url: URL?, webView: WKWebView) - func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, - completion: @escaping (WKNavigationActionPolicy) -> Void, - webView: WKWebView) + + /// Delegate for handling tab navigation events. + var tabNavigationHandler: DuckPlayerTabNavigationHandling? { get set } + + /// The DuckPlayer instance used for handling video playback. + var duckPlayer: DuckPlayerControlling { get } + + /// Handles URL changes in the web view. + /// + /// - Parameter webView: The web view where the URL change occurred. + /// - Returns: The result of handling the URL change. + func handleURLChange(webView: WKWebView) -> DuckPlayerNavigationHandlerURLChangeResult + + /// Handles the back navigation action in the web view. + /// + /// - Parameter webView: The web view to navigate back in. func handleGoBack(webView: WKWebView) + + /// Handles the reload action in the web view. + /// + /// - Parameter webView: The web view to reload. func handleReload(webView: WKWebView) + + /// Performs actions when the handler is attached to a web view. + /// + /// - Parameter webView: The web view being attached. func handleAttach(webView: WKWebView) + + /// Handles the start of page loading in the web view. + /// + /// - Parameter webView: The web view that started loading. + func handleDidStartLoading(webView: WKWebView) + + /// Handles the completion of page loading in the web view. + /// + /// - Parameter webView: The web view that finished loading. + func handleDidFinishLoading(webView: WKWebView) + + /// Converts a standard YouTube URL to its Duck Player equivalent if applicable. + /// + /// - Parameter url: The YouTube URL to convert. + /// - Returns: A Duck Player URL if applicable. func getDuckURLFor(_ url: URL) -> URL - func handleEvent(event: DuckPlayerNavigationEvent, - url: URL?, - navigationAction: WKNavigationAction?) - func shouldOpenInNewTab(_ navigationAction: WKNavigationAction, webView: WKWebView) -> Bool + /// Handles navigation actions to Duck Player URLs. + /// + /// - Parameters: + /// - navigationAction: The navigation action to handle. + /// - webView: The web view where navigation is occurring. + func handleDuckNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView) + + /// Decides whether to cancel navigation to prevent opening the YouTube app from the web view. + /// + /// - Parameters: + /// - navigationAction: The navigation action to evaluate. + /// - webView: The web view where navigation is occurring. + /// - Returns: `true` if the navigation should be canceled, `false` otherwise. + func handleDelegateNavigation(navigationAction: WKNavigationAction, webView: WKWebView) -> Bool +} + +/// Protocol defining the tab navigation handling for Duck Player. +protocol DuckPlayerTabNavigationHandling: AnyObject { + /// Opens a new tab for the specified URL. + /// + /// - Parameter url: The URL to open in a new tab. + func openTab(for url: URL) + + /// Closes the current tab. + func closeTab() +} + +/// Protocol defining a navigation action for Duck Player. +protocol NavigationActionProtocol { + + var request: URLRequest { get } + var isTargetingMainFrame: Bool { get } + var navigationType: WKNavigationType { get } +} + +extension WKNavigationAction: NavigationActionProtocol { + /// Indicates whether the navigation action targets the main frame. + var isTargetingMainFrame: Bool { + return self.targetFrame?.isMainFrame ?? false + } } diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift b/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift index c650284fcc..2100b563bd 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerSettings.swift @@ -21,13 +21,14 @@ import BrowserServicesKit import Combine import Core +/// Represents the different modes for Duck Player operation. enum DuckPlayerMode: Equatable, Codable, CustomStringConvertible, CaseIterable { case enabled, alwaysAsk, disabled private static let enabledString = "enabled" private static let alwaysAskString = "alwaysAsk" private static let neverString = "disabled" - + var description: String { switch self { case .enabled: @@ -38,7 +39,7 @@ enum DuckPlayerMode: Equatable, Codable, CustomStringConvertible, CaseIterable { return UserText.duckPlayerDisabledLabel } } - + var stringValue: String { switch self { case .enabled: @@ -50,6 +51,9 @@ enum DuckPlayerMode: Equatable, Codable, CustomStringConvertible, CaseIterable { } } + /// Initializes a `DuckPlayerMode` from a string value. + /// + /// - Parameter stringValue: The string representation of the mode. init?(stringValue: String) { switch stringValue { case Self.enabledString: @@ -64,20 +68,46 @@ enum DuckPlayerMode: Equatable, Codable, CustomStringConvertible, CaseIterable { } } +/// Protocol defining the settings for Duck Player. protocol DuckPlayerSettings: AnyObject { + /// Publisher that emits when Duck Player settings change. var duckPlayerSettingsPublisher: AnyPublisher { get } + + /// The current mode of Duck Player. var mode: DuckPlayerMode { get } + + /// Indicates if the "Always Ask" overlay has been hidden. var askModeOverlayHidden: Bool { get } + + /// Flag to allow the first video to play in Youtube var allowFirstVideo: Bool { get set } + /// Determines if Duck Player should open videos in a new tab. + var openInNewTab: Bool { get } + + /// Initializes a new instance with the provided app settings and privacy configuration manager. + /// + /// - Parameters: + /// - appSettings: The application settings. + /// - privacyConfigManager: The privacy configuration manager. init(appSettings: AppSettings, privacyConfigManager: PrivacyConfigurationManaging) + /// Sets the Duck Player mode. + /// + /// - Parameter mode: The mode to set. func setMode(_ mode: DuckPlayerMode) + + /// Sets whether the "Always Ask" overlay has been hidden. + /// + /// - Parameter overlayHidden: A Boolean indicating if the overlay is hidden. func setAskModeOverlayHidden(_ overlayHidden: Bool) + + /// Triggers a notification to update subscribers about settings changes. func triggerNotification() } +/// Default implementation of `DuckPlayerSettings`. final class DuckPlayerSettingsDefault: DuckPlayerSettings { private var appSettings: AppSettings @@ -102,6 +132,11 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { duckPlayerSettingsSubject.eraseToAnyPublisher() } + /// Initializes a new instance with the provided app settings and privacy configuration manager. + /// + /// - Parameters: + /// - appSettings: The application settings. + /// - privacyConfigManager: The privacy configuration manager. init(appSettings: AppSettings = AppDependencyProvider.shared.appSettings, privacyConfigManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager) { self.appSettings = appSettings @@ -111,6 +146,7 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { registerForNotificationChanges() } + /// DuckPlayer features are only available in these domains public struct OriginDomains { static let duckduckgo = "duckduckgo.com" static let youtubeWWW = "www.youtube.com" @@ -118,26 +154,33 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { static let youtubeMobile = "m.youtube.com" } + /// The current mode of Duck Player. var mode: DuckPlayerMode { - let experiment = DuckPlayerLaunchExperiment() - if isFeatureEnabled && experiment.isEnrolled && experiment.isExperimentCohort { + if isFeatureEnabled { return appSettings.duckPlayerMode } else { return .disabled } } + /// Indicates if the "Always Ask" overlay has been hidden. var askModeOverlayHidden: Bool { - let experiment = DuckPlayerLaunchExperiment() - if isFeatureEnabled && experiment.isEnrolled && experiment.isExperimentCohort { + if isFeatureEnabled { return appSettings.duckPlayerAskModeOverlayHidden } else { return false } } + /// Flag to allow the first video to play without redirection. var allowFirstVideo: Bool = false + /// Determines if Duck Player should open videos in a new tab. + var openInNewTab: Bool { + return appSettings.duckPlayerOpenInNewTab + } + + /// Registers a publisher to listen for changes in the privacy configuration. private func registerConfigPublisher() { isFeatureEnabledCancellable = privacyConfigManager.updatesPublisher .map { [weak privacyConfigManager] in @@ -149,6 +192,7 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { } } + /// Registers for notification changes in Duck Player settings. private func registerForNotificationChanges() { NotificationCenter.default.addObserver(self, selector: #selector(publishUpdate), @@ -156,6 +200,9 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { object: nil) } + /// Sets the Duck Player mode. + /// + /// - Parameter mode: The mode to set. func setMode(_ mode: DuckPlayerMode) { if mode != appSettings.duckPlayerMode { appSettings.duckPlayerMode = mode @@ -163,6 +210,9 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { } } + /// Sets whether the "Always Ask" overlay has been hidden. + /// + /// - Parameter overlayHidden: A Boolean indicating if the overlay is hidden. func setAskModeOverlayHidden(_ overlayHidden: Bool) { if overlayHidden != appSettings.duckPlayerAskModeOverlayHidden { appSettings.duckPlayerAskModeOverlayHidden = overlayHidden @@ -170,10 +220,14 @@ final class DuckPlayerSettingsDefault: DuckPlayerSettings { } } + /// Publishes an update notification when settings change. + /// + /// - Parameter notification: The notification received. @objc private func publishUpdate(_ notification: Notification) { triggerNotification() } + /// Triggers a notification to update subscribers about settings changes. func triggerNotification() { duckPlayerSettingsSubject.send() } diff --git a/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift b/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift index b3463bbb8d..29e0bed47f 100644 --- a/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift +++ b/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift @@ -28,7 +28,7 @@ import DuckPlayer final class YoutubeOverlayUserScript: NSObject, Subfeature { - var duckPlayer: DuckPlayerProtocol + var duckPlayer: DuckPlayerControlling private var cancellables = Set() var statisticsStore: StatisticsStore private var duckPlayerStorage: DuckPlayerStorage @@ -36,7 +36,7 @@ final class YoutubeOverlayUserScript: NSObject, Subfeature { static let featureName = "duckPlayer" } - init(duckPlayer: DuckPlayerProtocol, + init(duckPlayer: DuckPlayerControlling, statisticsStore: StatisticsStore = StatisticsUserDefaults(), duckPlayerStorage: DuckPlayerStorage = DefaultDuckPlayerStorage()) { self.duckPlayer = duckPlayer diff --git a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift index 5e83db541c..f28e8a46c3 100644 --- a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift +++ b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift @@ -24,7 +24,7 @@ import Combine final class YoutubePlayerUserScript: NSObject, Subfeature { - var duckPlayer: DuckPlayerProtocol + var duckPlayer: DuckPlayerControlling private var cancellables = Set() struct Constants { @@ -40,7 +40,7 @@ final class YoutubePlayerUserScript: NSObject, Subfeature { static let telemetryEvent = "telemetryEvent" } - init(duckPlayer: DuckPlayerProtocol) { + init(duckPlayer: DuckPlayerControlling) { self.duckPlayer = duckPlayer super.init() subscribeToDuckPlayerMode() diff --git a/DuckDuckGo/OmniBar.swift b/DuckDuckGo/OmniBar.swift index 61850c6f2c..c2b6877be7 100644 --- a/DuckDuckGo/OmniBar.swift +++ b/DuckDuckGo/OmniBar.swift @@ -263,6 +263,7 @@ class OmniBar: UIView { let icon = PrivacyIconLogic.privacyIcon(for: url) privacyInfoContainer.privacyIcon.updateIcon(icon) + customIconView.isHidden = true } public func updatePrivacyIcon(for privacyInfo: PrivacyInfo?) { @@ -275,11 +276,11 @@ class OmniBar: UIView { showCustomIcon(icon: .duckPlayer) return } - - customIconView.isHidden = true + privacyInfoContainer.privacyIcon.isHidden = privacyInfo.isSpecialErrorPageVisible let icon = PrivacyIconLogic.privacyIcon(for: privacyInfo) privacyInfoContainer.privacyIcon.updateIcon(icon) + customIconView.isHidden = true } // Support static custom icons, for things like internal pages, for example diff --git a/DuckDuckGo/RootDebugViewController.swift b/DuckDuckGo/RootDebugViewController.swift index 08dea4d8ae..0283c332dd 100644 --- a/DuckDuckGo/RootDebugViewController.swift +++ b/DuckDuckGo/RootDebugViewController.swift @@ -47,9 +47,6 @@ class RootDebugViewController: UITableViewController { case newTabPageSections = 674 case onboarding = 676 case resetSyncPromoPrompts = 677 - case resetDuckPlayerExperiment = 678 - case overrideDuckPlayerExperiment = 679 - case overrideDuckPlayerExperimentControl = 680 case resetTipKit = 681 } @@ -191,17 +188,8 @@ class RootDebugViewController: UITableViewController { let syncPromoPresenter = SyncPromoManager(syncService: sync) syncPromoPresenter.resetPromos() ActionMessageView.present(message: "Sync Promos reset") - case .resetDuckPlayerExperiment: - DuckPlayerLaunchExperiment().cleanup() - ActionMessageView.present(message: "Experiment Settings deleted. You'll be assigned a random cohort") case .resetTipKit: tipKitUIActionHandler?.resetTipKitTapped() - case .overrideDuckPlayerExperiment: - DuckPlayerLaunchExperiment().override() - ActionMessageView.present(message: "Overriding experiment. You are now in the 'experiment' group. Restart the app to complete") - case .overrideDuckPlayerExperimentControl: - DuckPlayerLaunchExperiment().override(control: true) - ActionMessageView.present(message: "Overriding experiment. You are now in the 'control' group. Restart the app to complete") } } } diff --git a/DuckDuckGo/SettingsMainSettingsView.swift b/DuckDuckGo/SettingsMainSettingsView.swift index b851cda021..02b487031b 100644 --- a/DuckDuckGo/SettingsMainSettingsView.swift +++ b/DuckDuckGo/SettingsMainSettingsView.swift @@ -70,12 +70,10 @@ struct SettingsMainSettingsView: View { // Duck Player // We need to hide the settings until the user is enrolled in the experiment - if DuckPlayerLaunchExperiment().isEnrolled && DuckPlayerLaunchExperiment().isExperimentCohort { - if viewModel.isInternalUser || viewModel.state.duckPlayerEnabled { - NavigationLink(destination: SettingsDuckPlayerView().environmentObject(viewModel)) { - SettingsCellView(label: UserText.duckPlayerFeatureName, - image: Image("SettingsDuckPlayer")) - } + if viewModel.state.duckPlayerEnabled { + NavigationLink(destination: SettingsDuckPlayerView().environmentObject(viewModel)) { + SettingsCellView(label: UserText.duckPlayerFeatureName, + image: Image("SettingsDuckPlayer")) } } } diff --git a/DuckDuckGo/TabManager.swift b/DuckDuckGo/TabManager.swift index ba67adae9b..46efdc1369 100644 --- a/DuckDuckGo/TabManager.swift +++ b/DuckDuckGo/TabManager.swift @@ -37,7 +37,7 @@ class TabManager { private let historyManager: HistoryManaging private let syncService: DDGSyncing private var previewsSource: TabPreviewsSource - private var duckPlayer: DuckPlayerProtocol + private var duckPlayer: DuckPlayerControlling private var privacyProDataReporter: PrivacyProDataReporting private let contextualOnboardingPresenter: ContextualOnboardingPresenting private let contextualOnboardingLogic: ContextualOnboardingLogic diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 7f05eb30ba..94a89dec38 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -317,7 +317,7 @@ class TabViewController: UIViewController { bookmarksDatabase: CoreDataDatabase, historyManager: HistoryManaging, syncService: DDGSyncing, - duckPlayer: DuckPlayerProtocol?, + duckPlayer: DuckPlayerControlling?, privacyProDataReporter: PrivacyProDataReporting, contextualOnboardingPresenter: ContextualOnboardingPresenting, contextualOnboardingLogic: ContextualOnboardingLogic, @@ -353,7 +353,7 @@ class TabViewController: UIViewController { let historyManager: HistoryManaging let historyCapture: HistoryCapture - weak var duckPlayer: DuckPlayerProtocol? + weak var duckPlayer: DuckPlayerControlling? var duckPlayerNavigationHandler: DuckPlayerNavigationHandling? let contextualOnboardingPresenter: ContextualOnboardingPresenting @@ -367,7 +367,7 @@ class TabViewController: UIViewController { historyManager: HistoryManaging, syncService: DDGSyncing, certificateTrustEvaluator: CertificateTrustEvaluating = CertificateTrustEvaluator(), - duckPlayer: DuckPlayerProtocol?, + duckPlayer: DuckPlayerControlling?, privacyProDataReporter: PrivacyProDataReporting, contextualOnboardingPresenter: ContextualOnboardingPresenting, contextualOnboardingLogic: ContextualOnboardingLogic, @@ -395,6 +395,9 @@ class TabViewController: UIViewController { self.featureFlagger = featureFlagger self.subscriptionCookieManager = subscriptionCookieManager super.init(coder: aDecoder) + + // Assign itself as tabNavigationHandler for DuckPlayer + duckPlayerNavigationHandler?.tabNavigationHandler = self } required init?(coder aDecoder: NSCoder) { @@ -736,7 +739,12 @@ class TabViewController: UIViewController { progressWorker.progressDidChange(webView.estimatedProgress) case #keyPath(WKWebView.url): - webViewUrlHasChanged() + // A short delay is required here, because the URL takes some time + // to propagate to the webView.url property accessor and might not + // be immediately available in the observer + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.webViewUrlHasChanged() + } case #keyPath(WKWebView.canGoBack): delegate?.tabLoadingStateDidChange(tab: self) @@ -753,37 +761,17 @@ class TabViewController: UIViewController { } func webViewUrlHasChanged() { + + // Handle DuckPlayer Navigation URL changes + if let handler = duckPlayerNavigationHandler, + let currentURL = webView.url { + _ = handler.handleURLChange(webView: webView) + } + if url == nil { url = webView.url } else if let currentHost = url?.host, let newHost = webView.url?.host, currentHost == newHost { url = webView.url - - // decideForPolicy is not called for JS navigation - // This ensures DuckPlayer works on internal JS navigation based on - // URL Changes - - if let url, - url.isYoutubeVideo { - - duckPlayerNavigationHandler?.handleEvent(event: .youtubeVideoPageVisited, - url: url, - navigationAction: nil) - - if duckPlayerNavigationHandler?.duckPlayer.settings.mode == .enabled { - duckPlayerNavigationHandler?.handleJSNavigation(url: url, webView: webView) - } - } - - - } - if let url { - duckPlayerNavigationHandler?.referrer = url.isYoutube ? .youtube : .other - - // Open in new tab if required - // If the lastRenderedURL is nil, it means we're already in a new tab - if webView.url != nil && lastRenderedURL != nil { - duckPlayerNavigationHandler?.handleEvent(event: .JSTriggeredNavigation, url: webView.url, navigationAction: nil) - } } } @@ -840,7 +828,7 @@ class TabViewController: UIViewController { public func reload() { updateContentMode() cachedRuntimeConfigurationForDomain = [:] - if let url = webView.url, url.isDuckPlayer { + if let handler = duckPlayerNavigationHandler { duckPlayerNavigationHandler?.handleReload(webView: webView) } else { webView.reload() @@ -1325,6 +1313,8 @@ extension TabViewController: WKNavigationDelegate { SKStoreReviewController.requestReview(in: scene) appRatingPrompt.shown() } + + duckPlayerNavigationHandler?.handleDidStartLoading(webView: webView) } func webView(_ webView: WKWebView, @@ -1504,6 +1494,11 @@ extension TabViewController: WKNavigationDelegate { daxDialogsDebouncer.debounce(for: 0.8) { [weak self] in self?.showDaxDialogOrStartTrackerNetworksAnimationIfNeeded() } + + // DuckPlayer finish loading actions + if let handler = duckPlayerNavigationHandler { + handler.handleDidFinishLoading(webView: webView) + } Task { @MainActor in if await webView.isCurrentSiteReferredFromDuckDuckGo { @@ -1728,6 +1723,15 @@ extension TabViewController: WKNavigationDelegate { } } + // Ask DuckPlayer to handle navigation if possible + if let handler = duckPlayerNavigationHandler { + + if handler.handleDelegateNavigation(navigationAction: navigationAction, webView: webView) { + decisionHandler(.cancel) + return + } + } + if let url = navigationAction.request.url, !url.isDuckDuckGoSearch, true == shouldWaitUntilContentBlockingIsLoaded({ [weak self, webView /* decision handler must be called */] in @@ -1824,9 +1828,6 @@ extension TabViewController: WKNavigationDelegate { if url.isDuckDuckGoSearch { StatisticsLoader.shared.refreshSearchRetentionAtb() privacyProDataReporter.saveSearchCount() - - // Duck Player Search Experiment - DuckPlayerLaunchExperiment(duckPlayerMode: duckPlayer?.settings.mode).fireSearchPixels() } self.delegate?.closeFindInPage(tab: self) @@ -1883,23 +1884,6 @@ extension TabViewController: WKNavigationDelegate { if navigationAction.isTargetingMainFrame(), navigationAction.navigationType == .backForward { adClickAttributionLogic.onBackForwardNavigation(mainFrameURL: webView.url) } - - if navigationAction.isTargetingMainFrame(), - url.isYoutubeVideo { - - duckPlayerNavigationHandler?.handleEvent(event: .youtubeVideoPageVisited, - url: url, - navigationAction: navigationAction) - - // Handle decidePolicy For - if duckPlayerNavigationHandler?.duckPlayer.settings.mode == .enabled { - duckPlayerNavigationHandler?.handleDecidePolicyFor(navigationAction, - completion: completion, - webView: webView) - return - } - - } let schemeType = SchemeHandler.schemeType(for: url) self.blobDownloadTargetFrame = nil @@ -1922,18 +1906,11 @@ extension TabViewController: WKNavigationDelegate { performBlobNavigation(navigationAction, completion: completion) case .duck: - duckPlayerNavigationHandler?.handleEvent(event: .youtubeVideoPageVisited, - url: url, - navigationAction: navigationAction) - - // Validate Duck Player setting to open in new tab or locally - if duckPlayerNavigationHandler?.shouldOpenInNewTab(navigationAction, webView: webView) ?? false { - delegate?.tab(self, didRequestNewTabForUrl: url, openedByPage: false, inheritingAttribution: nil) - } else { - duckPlayerNavigationHandler?.handleNavigation(navigationAction, webView: webView) + if navigationAction.isTargetingMainFrame() { + duckPlayerNavigationHandler?.handleDuckNavigation(navigationAction, webView: webView) + completion(.cancel) + return } - completion(.cancel) - return case .unknown: if navigationAction.navigationType == .linkActivated { @@ -3133,3 +3110,23 @@ extension TabViewController: SpecialErrorPageUserScriptDelegate { } } + +// This Protocol allows DuckPlayerHandler access tabs +extension TabViewController: DuckPlayerTabNavigationHandling { + + func openTab(for url: URL) { + delegate?.tab(self, + didRequestNewTabForUrl: url, + openedByPage: true, + inheritingAttribution: adClickAttributionLogic.state) + + } + + func closeTab() { + if openingTab != nil { + delegate?.tabDidRequestClose(self) + return + } + } + +} diff --git a/DuckDuckGo/UserScripts.swift b/DuckDuckGo/UserScripts.swift index 4b003e0a57..144686acb9 100644 --- a/DuckDuckGo/UserScripts.swift +++ b/DuckDuckGo/UserScripts.swift @@ -36,7 +36,7 @@ final class UserScripts: UserScriptsProvider { let autoconsentUserScript: AutoconsentUserScript var specialPages: SpecialPagesUserScript? - var duckPlayer: DuckPlayerProtocol? { + var duckPlayer: DuckPlayerControlling? { didSet { initializeDuckPlayer() } diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index b9ac7ba5ec..331dc45c98 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1275,7 +1275,7 @@ But if you *do* want a peek under the hood, you can find more information about public static let duckPlayerAskLabel = NSLocalizedString("duckPlayer.ask.label", value: "Ask every time", comment: "Text displayed when DuckPlayer is in 'Ask' mode.") public static let duckPlayerDisabledLabel = NSLocalizedString("duckPlayer.never.label", value: "Never", comment: "Text displayed when DuckPlayer is in off.") - public static let settingsOpenVideosInDuckPlayerLabel = NSLocalizedString("duckplayer.settings.open-videos-in", value: "Open Videos in Duck Player", comment: "Settings screen cell text for DuckPlayer settings") + public static let settingsOpenVideosInDuckPlayerLabel = NSLocalizedString("duckplayer.settings.open-videos-in", value: "Open YouTube Videos in Duck Player", comment: "Settings screen cell text for DuckPlayer settings") public static let duckPlayerFeatureName = NSLocalizedString("duckplayer.settings.title", value: "Duck Player", comment: "Settings screen cell text for DuckPlayer settings") public static let settingsOpenDuckPlayerNewTabLabel = NSLocalizedString("duckplayer.settings.open-new-tab-label", value: "Open Duck Player in a new tab", comment: "Settings screen cell text for DuckPlayer settings to open in new tab") diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index ff3a3cfbea..b4b69e4eb4 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -1068,7 +1068,7 @@ "duckplayer.settings.open-new-tab-label" = "Open Duck Player in a new tab"; /* Settings screen cell text for DuckPlayer settings */ -"duckplayer.settings.open-videos-in" = "Open Videos in Duck Player"; +"duckplayer.settings.open-videos-in" = "Open YouTube Videos in Duck Player"; /* Settings screen cell text for DuckPlayer settings */ "duckplayer.settings.title" = "Duck Player"; diff --git a/DuckDuckGoTests/DuckPlayerExperimentTests.swift b/DuckDuckGoTests/DuckPlayerExperimentTests.swift deleted file mode 100644 index 56040b0dee..0000000000 --- a/DuckDuckGoTests/DuckPlayerExperimentTests.swift +++ /dev/null @@ -1,435 +0,0 @@ -// -// DuckPlayerExperimentTests.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 DuckDuckGo -import Core - -public class MockDuckPlayerExperimentDateProvider: DuckPlayerExperimentDateProvider { - private var customDate: Date? - - public var currentDate: Date { - return customDate ?? Date() - } - - public init(customDate: Date? = nil) { - self.customDate = customDate - } - - public func setCurrentDate(_ date: Date) { - self.customDate = date - } - - public func resetToCurrentDate() { - self.customDate = nil - } -} - - -final class DuckPlayerExperimentPixelFireMock: DuckPlayerExperimentPixelFiring { - - static private(set) var capturedPixelEventHistory: [(pixel: Pixel.Event, params: [String: String])] = [] - - static func fireDuckPlayerExperimentPixel(pixel: Pixel.Event, withAdditionalParameters params: [String: String]) { - capturedPixelEventHistory.append((pixel: pixel, params: params)) - } - - static func tearDown() { - capturedPixelEventHistory = [] - } -} - - -final class DuckPlayerExperimentDailyPixelFireMock: DuckPlayerExperimentPixelFiring { - - static private(set) var capturedPixelEventHistory: [(pixel: Pixel.Event, params: [String: String])] = [] - - static func fireDuckPlayerExperimentPixel(pixel: Pixel.Event, withAdditionalParameters params: [String: String]) { - capturedPixelEventHistory.append((pixel: pixel, params: params)) - } - - static func tearDown() { - capturedPixelEventHistory = [] - } -} - - -final class DuckPlayerLaunchExperimentTests: XCTestCase { - - private var sut: DuckPlayerLaunchExperiment! - private var userDefaults: UserDefaults! - private var dateProvider = MockDuckPlayerExperimentDateProvider() - - override func setUp() { - super.setUp() - // Setting up a temporary UserDefaults to isolate tests - userDefaults = UserDefaults(suiteName: "DuckPlayerLaunchExperimentTests") - userDefaults.removePersistentDomain(forName: "DuckPlayerLaunchExperimentTests") - } - - override func tearDown() { - sut = nil - userDefaults = nil - DuckPlayerExperimentPixelFireMock.tearDown() - DuckPlayerExperimentDailyPixelFireMock.tearDown() - super.tearDown() - } - - func testAssignUserToCohort_AssignsCohotsAndFiresPixels() { - - // Set a fixed date to 2024.09.10 - dateProvider.setCurrentDate(Date(timeIntervalSince1970: 1725926400)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - - sut.cleanup() - XCTAssertFalse(sut.isEnrolled, "User should not be enrolled initially.") - - sut.assignUserToCohort() - - XCTAssertTrue(sut.isEnrolled, "User should be enrolled after assigning to cohort.") - XCTAssertNotNil(sut.experimentCohortV2, "Experiment cohort should be assigned.") - XCTAssertNotNil(sut.enrollmentDateV2, "Enrollment date should be set.") - XCTAssertEqual(DuckPlayerLaunchExperiment.formattedDate(sut.enrollmentDateV2 ?? Date()), "20240910", "The assigned date should match.") - - // Check the pixel event history - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - XCTAssertEqual(history.count, 1, "One pixel event should be fired.") - if let firstEvent = history.first { - XCTAssertEqual(firstEvent.pixel, .duckplayerExperimentCohortAssign, "Enrollment pixel should be duckplayerExperimentCohortAssign") - XCTAssert(["control", "experiment"].contains(firstEvent.params["variant"]), "The variant is incorrect") - XCTAssertEqual(firstEvent.params["enrollment"], "20240910", "The assigned date should be valid.") - } - } - - func testAssignUserToCohortMultipleTimes_DoesNotReassignNorFiresMultiplePixels() { - - // Set a fixed date to 2024.09.10 - dateProvider.setCurrentDate(Date(timeIntervalSince1970: 1725926400)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - - sut.cleanup() - XCTAssertFalse(sut.isEnrolled, "User should not be enrolled initially.") - - sut.assignUserToCohort() - XCTAssertEqual(DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory.count, 1, "Enrollment pixel should have fired") - - DuckPlayerExperimentPixelFireMock.tearDown() - - // Change the date to something in the future - dateProvider.setCurrentDate(Date(timeIntervalSince1970: 1726185600)) - sut.assignUserToCohort() - XCTAssertEqual(DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory.count, 0, "Enrollment pixel should not have fired again") - XCTAssertEqual(sut.isEnrolled, true, "The assigned date should not change.") - XCTAssertEqual(DuckPlayerLaunchExperiment.formattedDate(sut.enrollmentDateV2 ?? Date()), "20240910", "The assigned date should not change.") - } - - func testIfUserIsEnrolled_SearchDailyPixelsFire() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - sut.assignUserToCohort() - - for day in 0...14 { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day, but only one should be registered - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let dailyPixel = history.filter { $0.pixel == .duckplayerExperimentDailySearch } - - // Assign cohort - XCTAssertEqual(dailyPixel.count, 15, "There must be 15 daily pixels") - - for (index, value) in dailyPixel.enumerated() { - XCTAssertEqual(value.params["day"], "\(index)") - XCTAssert(["control", "experiment"].contains(value.params["variant"]), "The variant is incorrect") - XCTAssertEqual(value.params["enrollment"], "20240910", "The assigned date is incorrect.") - } - - } - - func testIfUserIsEnrolled_SearchDailyPixelsFireWhenNotUsedDaily() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - // Assign cohort - sut.assignUserToCohort() - - let fireDays = [0, 4, 11, 12] - for day in fireDays { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day, but only one should be registered - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let dailyPixel = history.filter { $0.pixel == .duckplayerExperimentDailySearch } - - XCTAssertEqual(dailyPixel.count, 4, "There must be 4 daily pixels") - - if dailyPixel.count == 4 { - XCTAssertEqual(dailyPixel[0].params["day"], "0") - XCTAssertEqual(dailyPixel[1].params["day"], "4") - XCTAssertEqual(dailyPixel[2].params["day"], "11") - XCTAssertEqual(dailyPixel[3].params["day"], "12") - } - } - - func testIfUserIsEnrolled_WeeklyPixelsFire() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - // Assign cohort - sut.assignUserToCohort() - - for day in 0...13 { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day, but only one should be registered - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let weeklyPixel = history.filter { $0.pixel == .duckplayerExperimentWeeklySearch } - - XCTAssertEqual(weeklyPixel.count, 2, "There must be 2 weekly pixels") - - for (index, value) in weeklyPixel.enumerated() { - XCTAssertEqual(value.params["week"], "\(index+1)") - XCTAssert(["control", "experiment"].contains(value.params["variant"]), "The variant is incorrect") - XCTAssertEqual(value.params["enrollment"], "20240910", "The assigned date is incorrect.") - - } - - } - - func testIfUserIsEnrolled_WeeklyPixelsFireWhenNotUsedDaily() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - // Assign cohort - sut.assignUserToCohort() - - let fireDays = [0, 6, 11, 12] - for day in fireDays { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let weeklyPixel = history.filter { $0.pixel == .duckplayerExperimentWeeklySearch } - - XCTAssertEqual(weeklyPixel.count, 2, "There must be 2 weekly pixels") - - if weeklyPixel.count == 2 { - XCTAssertEqual(weeklyPixel[0].params["week"], "1") - XCTAssertEqual(weeklyPixel[1].params["week"], "2") - } - - } - - func testIfUserIsEnrolled_WeeklyPixelsFireWhenNotUsedWeek2() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - sut.assignUserToCohort() - - let fireDays = [0, 2, 3, 6] - for day in fireDays { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let weeklyPixel = history.filter { $0.pixel == .duckplayerExperimentWeeklySearch } - - // Assign cohort - XCTAssertEqual(weeklyPixel.count, 1, "There must be 2 weekly pixels") - - if weeklyPixel.count == 2 { - XCTAssertEqual(weeklyPixel[0].params["week"], "1") - } - - } - - func testIfUserIsEnrolled_WeeklyPixelsFireWhenNotUsedWeek1() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - // Assign cohort - sut.assignUserToCohort() - - let fireDays = [0, 8, 9, 12] - for day in fireDays { - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (Double(day) * 86400))) - - // Fire a random number of search pixels per day - for _ in 1...Int.random(in: 1..<10) { - sut.fireSearchPixels() - } - } - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let weeklyPixel = history.filter { $0.pixel == .duckplayerExperimentWeeklySearch } - - XCTAssertEqual(weeklyPixel.count, 1, "There must be 2 weekly pixels") - - if weeklyPixel.count == 2 { - XCTAssertEqual(weeklyPixel[0].params["week"], "2") - } - - } - - func testIfUserIsEnrolled_SearchPixelFires() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(userDefaults: userDefaults, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - // Assign cohort - sut.assignUserToCohort() - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - sut.fireSearchPixels() - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - - let searchPixel = history.filter { $0.pixel == .duckplayerExperimentSearch } - - if history.count == 1 { - XCTAssert(["control", "experiment"].contains(searchPixel.first?.params["variant"]), "The variant is incorrect") - XCTAssertEqual(searchPixel.first?.params["enrollment"], "20240910", "The assigned date should be valid.") - } - - } - - func testIfUserIsEnrolled_YoutubePixelFires() { - - // Set a fixed date to 2024.09.10 - let startDate: Double = 1725926400 - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate)) - - let sut = DuckPlayerLaunchExperiment(duckPlayerMode: .alwaysAsk, - referrer: .serp, - pixel: DuckPlayerExperimentPixelFireMock.self, - dateProvider: dateProvider) - sut.cleanup() - - sut.assignUserToCohort() - - XCTAssertTrue(sut.isEnrolled, "User should be enrolled after assigning to cohort.") - - dateProvider.setCurrentDate(Date(timeIntervalSince1970: startDate + (3 * 86400))) // Day 3 - sut.fireYoutubePixel(videoID: "testVideoID") - - let history = DuckPlayerExperimentPixelFireMock.capturedPixelEventHistory - let youtubePixel = history.filter { $0.pixel == .duckplayerExperimentYoutubePageView } - - // Validate that one YouTube pixel was fired - XCTAssertEqual(youtubePixel.count, 1, "There should be exactly one YouTube pixel fired.") - - if let firedPixel = youtubePixel.first { - XCTAssertEqual(firedPixel.params["day"], "3", "The day parameter is incorrect.") - XCTAssert(["control", "experiment"].contains(firedPixel.params["variant"]), "The variant is incorrect.") - XCTAssertEqual(firedPixel.params["enrollment"], "20240910", "The enrollment date should be valid.") - XCTAssertEqual(firedPixel.params["state"], "alwaysAsk", "The state parameter is incorrect.") - XCTAssertEqual(firedPixel.params["referrer"], "serp", "The referrer parameter is incorrect.") - } - - } - -} diff --git a/DuckDuckGoTests/DuckPlayerMocks.swift b/DuckDuckGoTests/DuckPlayerMocks.swift index c0d8b87f3a..f593fa4dd9 100644 --- a/DuckDuckGoTests/DuckPlayerMocks.swift +++ b/DuckDuckGoTests/DuckPlayerMocks.swift @@ -44,52 +44,119 @@ class MockWKNavigationDelegate: NSObject, WKNavigationDelegate { } class MockWebView: WKWebView { - var didStopLoadingCalled = false + + var lastLoadedRequest: URLRequest? - var lastResponseHTML: String? - var goToCalledWith: WKBackForwardListItem? - var canGoBackMock = false - var currentURL: URL? + var loadedRequests: [URLRequest] = [] + var loadCallCount = 0 + var loadCompletionHandler: (() -> Void)? + + /// The current URL of the web view. private var _url: URL? override var url: URL? { - return currentURL + return _url } - + + /// Sets the current URL of the web view. func setCurrentURL(_ url: URL) { - self.currentURL = url + self._url = url } - - override func stopLoading() { - didStopLoadingCalled = true - } - + + /// A simulated history stack to support navigation methods like `goBack()`. + var historyStack: [URL] = [] + + /// Indicates whether the `stopLoading()` method was called. + var didStopLoadingCalled = false + + // MARK: - Overridden Methods + override func load(_ request: URLRequest) -> WKNavigation? { lastLoadedRequest = request + loadedRequests.append(request) + loadCallCount += 1 + + // Simulate asynchronous loading + DispatchQueue.main.async { + self.loadCompletionHandler?() + } + return nil } - + override func reload() -> WKNavigation? { + // Simulate reload behavior if needed + return nil + } + + override func goBack() -> WKNavigation? { + if historyStack.count > 1 { + // Remove the current page + historyStack.removeLast() + // Set the URL to the previous page + setCurrentURL(historyStack.last!) + } return nil } + + override func stopLoading() { + didStopLoadingCalled = true + } + + // MARK: - Additional Helper Methods (if needed) + + /// Simulates navigating to a new URL. + func navigate(to url: URL) { + historyStack.append(url) + setCurrentURL(url) + } + + /// Resets the web view's state. + func reset() { + lastLoadedRequest = nil + loadedRequests.removeAll() + loadCallCount = 0 + didStopLoadingCalled = false + historyStack.removeAll() + _url = nil + loadCompletionHandler = nil + } } class MockNavigationAction: WKNavigationAction { private let _request: URLRequest private let _navigationType: WKNavigationType - - init(request: URLRequest, navigationType: WKNavigationType = .linkActivated ) { + private let _targetFrame: WKFrameInfo? + + init(request: URLRequest, navigationType: WKNavigationType = .linkActivated, targetFrame: WKFrameInfo? = nil) { self._request = request self._navigationType = navigationType + self._targetFrame = targetFrame } - + override var request: URLRequest { return _request } - + override var navigationType: WKNavigationType { return _navigationType } + + override var targetFrame: WKFrameInfo? { + return _targetFrame + } +} + +class MockFrameInfo: WKFrameInfo { + private let _isMainFrame: Bool + + init(isMainFrame: Bool) { + self._isMainFrame = isMainFrame + } + + override var isMainFrame: Bool { + return _isMainFrame + } } final class MockDuckPlayerSettings: DuckPlayerSettings { @@ -102,6 +169,7 @@ final class MockDuckPlayerSettings: DuckPlayerSettings { var mode: DuckPlayerMode = .disabled var askModeOverlayHidden: Bool = false var allowFirstVideo: Bool = false + var openInNewTab: Bool = false init(appSettings: AppSettings = AppSettingsMock(), privacyConfigManager: any BrowserServicesKit.PrivacyConfigurationManaging) {} func triggerNotification() {} @@ -116,7 +184,7 @@ final class MockDuckPlayerSettings: DuckPlayerSettings { } -final class MockDuckPlayer: DuckPlayerProtocol { +final class MockDuckPlayer: DuckPlayerControlling { func telemetryEvent(params: Any, message: WKScriptMessage) async -> (any Encodable)? { nil @@ -134,6 +202,7 @@ final class MockDuckPlayer: DuckPlayerProtocol { } func setHostViewController(_ vc: UIViewController) {} + func removeHostView() {} func initialSetupPlayer(params: Any, message: WKScriptMessage) async -> (any Encodable)? { nil @@ -168,13 +237,35 @@ final class MockDuckPlayer: DuckPlayerProtocol { } } +enum MockFeatureFlag: Hashable { + case duckPlayer, duckPlayerOpenInNewTab +} + final class MockDuckPlayerFeatureFlagger: FeatureFlagger { - func isFeatureOn(forProvider: F) -> Bool where F: BrowserServicesKit.FeatureFlagSourceProviding { - return true + var enabledFeatures: Set = [] + + func isFeatureOn(_ feature: MockFeatureFlag) -> Bool { + return enabledFeatures.contains(feature) + } + + func isFeatureOn(forProvider provider: F) -> Bool where F: FeatureFlagSourceProviding { + return !enabledFeatures.isEmpty } - } final class MockDuckPlayerStorage: DuckPlayerStorage { var userInteractedWithDuckPlayer: Bool = false } + +final class MockDuckPlayerTabNavigator: DuckPlayerTabNavigationHandling { + var openedURL: URL? + var closeTabCalled = false + + func openTab(for url: URL) { + openedURL = url + } + + func closeTab() { + closeTabCalled = true + } +} diff --git a/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift b/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift index 3b50f07aef..4b968fcd68 100644 --- a/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift +++ b/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift @@ -19,490 +19,567 @@ import XCTest import WebKit -import ContentScopeScripts import Combine import BrowserServicesKit - -class DuckPlayerExperimentMock: DuckPlayerLaunchExperimentHandling { - var duckPlayerMode: DuckDuckGo.DuckPlayerMode? - var isEnrolled = true - var isExperimentCohort = true - func assignUserToCohort() {} - func fireSearchPixels() {} - func fireYoutubePixel(videoID: String) {} -} +import Core @testable import DuckDuckGo class DuckPlayerNavigationHandlerTests: XCTestCase { - - var webView: WKWebView! + var mockWebView: MockWebView! - var mockNavigationDelegate: MockWKNavigationDelegate! var mockAppSettings: AppSettingsMock! var mockPrivacyConfig: PrivacyConfigurationManagerMock! var playerSettings: MockDuckPlayerSettings! var player: MockDuckPlayer! - var featureFlagger: FeatureFlagger! - + var featureFlagger: MockDuckPlayerFeatureFlagger! + var handler: DuckPlayerNavigationHandler! + var tabNavigator: MockDuckPlayerTabNavigator! + override func setUp() { super.setUp() - webView = WKWebView() mockWebView = MockWebView() - mockNavigationDelegate = MockWKNavigationDelegate() mockAppSettings = AppSettingsMock() + mockAppSettings.duckPlayerOpenInNewTab = false // Disable openInNewTab mockPrivacyConfig = PrivacyConfigurationManagerMock() + playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, + privacyConfigManager: mockPrivacyConfig) featureFlagger = MockDuckPlayerFeatureFlagger() - webView.navigationDelegate = mockNavigationDelegate - + player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) + + // Create and assign the mock tab navigator + tabNavigator = MockDuckPlayerTabNavigator() + + handler = DuckPlayerNavigationHandler(duckPlayer: player, + featureFlagger: featureFlagger, + appSettings: mockAppSettings, + pixelFiring: PixelFiringMock.self, + dailyPixelFiring: PixelFiringMock.self) + + // Inject the mock tab navigator + handler.tabNavigationHandler = tabNavigator + + PixelFiringMock.tearDown() } - + override func tearDown() { - webView.navigationDelegate = nil mockWebView = nil - mockNavigationDelegate = nil + mockAppSettings = nil + mockPrivacyConfig = nil + playerSettings = nil + player = nil + featureFlagger = nil + tabNavigator = nil + handler = nil + PixelFiringMock.tearDown() super.tearDown() } - - // Test for htmlTemplatePath existence - func testHtmlTemplatePathExists() { - let templatePath = DuckPlayerNavigationHandler.htmlTemplatePath - let fileExists = FileManager.default.fileExists(atPath: templatePath) - XCTAssertFalse(templatePath.isEmpty, "The template path should not be empty") - XCTAssertTrue(fileExists, "The template file should exist at the specified path") - } - - // Test for makeDuckPlayerRequest(from:) - func testMakeDuckPlayerRequestFromOriginalRequest() { - let originalRequest = URLRequest(url: URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")!) - - let duckPlayerRequest = DuckPlayerNavigationHandler.makeDuckPlayerRequest(from: originalRequest) - - XCTAssertEqual(duckPlayerRequest.url?.host, "www.youtube-nocookie.com") - XCTAssertEqual(duckPlayerRequest.url?.path, "/embed/abc123") - XCTAssertEqual(duckPlayerRequest.url?.query?.contains("t=10s"), true) - XCTAssertEqual(duckPlayerRequest.value(forHTTPHeaderField: "Referer"), "http://localhost") - XCTAssertEqual(duckPlayerRequest.httpMethod, "GET") - } - - // Test for makeDuckPlayerRequest(for:timestamp:) - func testMakeDuckPlayerRequestForVideoID() { - let videoID = "abc123" - let timestamp = "10s" - - let duckPlayerRequest = DuckPlayerNavigationHandler.makeDuckPlayerRequest(for: videoID, timestamp: timestamp) - - XCTAssertEqual(duckPlayerRequest.url?.host, "www.youtube-nocookie.com") - XCTAssertEqual(duckPlayerRequest.url?.path, "/embed/abc123") - XCTAssertEqual(duckPlayerRequest.url?.query?.contains("t=10s"), true) - XCTAssertEqual(duckPlayerRequest.value(forHTTPHeaderField: "Referer"), "http://localhost") - XCTAssertEqual(duckPlayerRequest.httpMethod, "GET") - } - - // Test for makeHTMLFromTemplate - func testMakeHTMLFromTemplate() { - let expectedHtml = try? String(contentsOfFile: DuckPlayerNavigationHandler.htmlTemplatePath) - let html = DuckPlayerNavigationHandler.makeHTMLFromTemplate() - XCTAssertEqual(html, expectedHtml) + + // MARK: - Test Cases + + @MainActor + func testHandleNavigation_MultipleCalls_WithinHalfSecond_OnlyProcessesFirst() async { + // Arrange + let youtubeURL1 = URL(string: "https://www.youtube.com/watch?v=abc123")! + let youtubeURL2 = URL(string: "https://www.youtube.com/watch?v=def456")! + let youtubeURL3 = URL(string: "https://www.youtube.com/watch?v=ghi789")! + let navigationAction1 = MockNavigationAction(request: URLRequest(url: youtubeURL1)) + let navigationAction2 = MockNavigationAction(request: URLRequest(url: youtubeURL2)) + let navigationAction3 = MockNavigationAction(request: URLRequest(url: youtubeURL3)) + playerSettings.mode = .enabled + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + mockWebView.loadedRequests = [] + mockWebView.loadCallCount = 0 + + // Act + handler.handleDuckNavigation(navigationAction1, webView: mockWebView) + handler.handleDuckNavigation(navigationAction2, webView: mockWebView) + handler.handleDuckNavigation(navigationAction3, webView: mockWebView) + + // Wait for a short time to allow any asynchronous processing + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert + XCTAssertEqual(mockWebView.loadCallCount, 1, "Expected only one request to be loaded") + if let loadedRequest = mockWebView.lastLoadedRequest { + XCTAssertEqual(loadedRequest.url?.scheme, "duck") + XCTAssertEqual(loadedRequest.url?.host, "player") + XCTAssertEqual(loadedRequest.url?.path, "/abc123") + } else { + XCTFail("DuckPlayer was not loaded") + } } - - - // MARK: - Decide policyFor Tests - - + @MainActor - func testDecidePolicyForVideoWasAlreadyHandled() { - - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - var navigationPolicy: WKNavigationActionPolicy? - - handler.lastHandledVideoID = "abc123" - - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy - expectation.fulfill() - }, webView: mockWebView) - - waitForExpectations(timeout: 1, handler: nil) - - XCTAssertEqual(navigationPolicy, .cancel, "Expected navigation policy to be .cancel") + func testHandleNavigation_DuckPlayerModeDisabled_AlwaysRedirectsToYouTube() async { + // Arrange + let duckPlayerURL = URL(string: "duck://player/123123")! + let navigationAction = MockNavigationAction(request: URLRequest(url: duckPlayerURL)) + playerSettings.mode = .disabled // DuckPlayer mode is disabled + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] // Feature is enabled + // Act + handler.handleDuckNavigation(navigationAction, webView: mockWebView) + + // Assert + // It should redirect to YouTube + if let loadedRequest = mockWebView.lastLoadedRequest { + XCTAssertEqual(loadedRequest.url?.host, "m.youtube.com") + XCTAssertEqual(loadedRequest.url?.path, "/watch") + XCTAssertTrue(loadedRequest.url?.query?.contains("v=123123") ?? false) + } else { + XCTFail("YouTube was not loaded") + } } @MainActor - func testDecidePolicyForVideosThatShouldLoadInYoutube() { - - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s&embeds_referring_euri=somevalue")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - var navigationPolicy: WKNavigationActionPolicy? - - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy - expectation.fulfill() - }, webView: mockWebView) - - waitForExpectations(timeout: 1, handler: nil) - - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") + func testHandleNavigation_OpenInYouTubeURL_AlwaysRedirectsToYouTube() async { + // Arrange + let openInYouTubeURL = URL(string: "duck://player/openInYoutube?v=12311")! + let navigationAction = MockNavigationAction(request: URLRequest(url: openInYouTubeURL)) + playerSettings.mode = .enabled // DuckPlayer mode is enabled + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] // Feature is enabled + + // Act + handler.handleDuckNavigation(navigationAction, webView: mockWebView) + // Assert + if let openedURL = tabNavigator.openedURL { + XCTAssertEqual(openedURL.host, "m.youtube.com") + XCTAssertEqual(openedURL.path, "/watch") + XCTAssertTrue(openedURL.query?.contains("v=12311") ?? false) + } else { + XCTFail("No URL was opened in a new tab") + } } - + @MainActor - func testDecidePolicyForVideosThatShouldLoadInDuckPlayer() { - - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! + func testHandleNavigation_YouTubeURL_WithWatchInYouTubeParameter_RedirectsToYouTube() async { + // Arrange + let youtubeURL = URL(string: "https://m.youtube.com/watch?v=abc123&embeds_referring_euri=true")! let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.mode = .enabled - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - var navigationPolicy: WKNavigationActionPolicy? - - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy - expectation.fulfill() - }, webView: mockWebView) - - waitForExpectations(timeout: 1, handler: nil) - - XCTAssertEqual(navigationPolicy, .cancel, "Expected navigation policy to be .cancel") + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + handler.handleDuckNavigation(navigationAction, webView: mockWebView) + // Assert + // It should redirect to YouTube + if let loadedRequest = mockWebView.lastLoadedRequest { + XCTAssertEqual(loadedRequest.url?.host, "m.youtube.com") + XCTAssertEqual(loadedRequest.url?.path, "/watch") + XCTAssertTrue(loadedRequest.url?.query?.contains("v=abc123") ?? false) + } else { + XCTFail("YouTube was not loaded") + } } - + @MainActor - func testDecidePolicyForOtherURLThatShouldLoadNormally() { - - let youtubeURL = URL(string: "https://www.google.com/")! + func testHandleNavigation_YouTubeURL_WithoutWatchInYouTubeParameter_RedirectsToDuckPlayer() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let expectation = self.expectation(description: "Completion handler called") - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) playerSettings.mode = .enabled - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - var navigationPolicy: WKNavigationActionPolicy? - - handler.handleDecidePolicyFor(navigationAction, completion: { policy in - navigationPolicy = policy - expectation.fulfill() - }, webView: mockWebView) - - waitForExpectations(timeout: 1, handler: nil) - - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + handler.handleDuckNavigation(navigationAction, webView: mockWebView) + if let loadedRequest = mockWebView.lastLoadedRequest { + XCTAssertEqual(loadedRequest.url?.scheme, "duck") + XCTAssertEqual(loadedRequest.url?.host, "player") + XCTAssertEqual(loadedRequest.url?.path, "/abc123") + XCTAssertTrue(loadedRequest.url?.query?.contains("referrer=other") ?? false) + } else { + XCTFail("DuckPlayer was not loaded") + } } - // MARK: - HandleJS Navigation Tests - @MainActor - func testJSNavigationForVideoWasAlreadyHandled() { - - let url: URL = URL(string: "https://www.example.com/")! - webView.load(URLRequest(url: url)) - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! - - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - - handler.lastHandledVideoID = "abc123" - handler.handleJSNavigation(url: youtubeURL, webView: webView) - - XCTAssertEqual(webView.url?.absoluteString, url.absoluteString) + func testHandleNavigation_ToDirectDuckURLInNewTab_OpenInNewTabWithParameters() async { + // Arrange + let youtubeURL = URL(string: "duck://player/abc123")! + let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) + playerSettings.mode = .enabled + playerSettings.openInNewTab = true + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + handler.handleDuckNavigation(navigationAction, webView: mockWebView) + + // Assert + if let openedURL = tabNavigator.openedURL { + XCTAssertEqual(openedURL.host, "player") + XCTAssertEqual(openedURL.path, "/abc123") + XCTAssertTrue(openedURL.query?.contains("referrer=other") ?? false) + } else { + XCTFail("No URL was opened in a new tab") + } } @MainActor - func testJSNavigationForVideoThatShouldLoadInYoutube() { - - let url: URL = URL(string: "https://www.example.com/")! - webView.load(URLRequest(url: url)) - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s&embeds_referring_euri=somevalue")! - - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - - handler.handleJSNavigation(url: youtubeURL, webView: webView) - - XCTAssertEqual(webView.url?.absoluteString, url.absoluteString) + func testHandleNavigation_ToYouTubeWatchInAskMode_RedirectsToDuckPlayerWithParameters() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) + playerSettings.mode = .alwaysAsk + playerSettings.openInNewTab = true + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + handler.handleDuckNavigation(navigationAction, webView: mockWebView) + + // Assert + if let openedURL = tabNavigator.openedURL { + XCTAssertEqual(openedURL.host, "player") + XCTAssertEqual(openedURL.path, "/abc123") + XCTAssertTrue(openedURL.query?.contains("referrer=other") ?? false) + } else { + XCTFail("No URL was opened in a new tab") + } } @MainActor - func testJSNavigationForVideoThatShouldLoadInDuckPlayer() { - - let url: URL = URL(string: "https://www.example.com/")! - webView.load(URLRequest(url: url)) - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + func testHandleDelegateNavigation_DuckPlayerURL_CancelNavigationAndLoadsDuckPlayerWithParamsInTab() async { + // Arrange + let duckPlayerURL = URL(string: "duck://player/abc123")! + let request = URLRequest(url: duckPlayerURL) + let mockFrameInfo = MockFrameInfo(isMainFrame: true) + let navigationAction = MockNavigationAction(request: request, targetFrame: mockFrameInfo) playerSettings.mode = .enabled - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - - handler.handleJSNavigation(url: youtubeURL, webView: webView) - - XCTAssertEqual(webView.url?.absoluteString, "duck://player/abc123?t=10s") + playerSettings.openInNewTab = true + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + handler.handleDuckNavigation(navigationAction, webView: mockWebView) + + // Assert + if let openedURL = tabNavigator.openedURL { + XCTAssertEqual(openedURL.host, "player") + XCTAssertEqual(openedURL.path, "/abc123") + XCTAssertTrue(openedURL.query?.contains("referrer=other") ?? false) + } else { + XCTFail("No URL was opened in a new tab") + } } - // MARK: Handle Navigation Tests - + // MARK: - Tests for handleURLChange + @MainActor - func testAgeRestrictedVideoShouldNotBeHandled() { - - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s&embeds_referring_euri=somevalue")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + func testHandleURLChange_MultipleCallsWithinOneSecond_ReturnsDuplicateNavigation() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + mockWebView.setCurrentURL(youtubeURL) playerSettings.mode = .enabled - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + let result1 = handler.handleURLChange(webView: mockWebView) - handler.handleNavigation(navigationAction, webView: webView) - XCTAssertEqual(webView.url, nil) + // Wait less than one second before calling again + try? await Task.sleep(nanoseconds: 500_000_000) + let result2 = handler.handleURLChange(webView: mockWebView) + + // Assert + if case .handled = result1 { + // Success + } else { + XCTFail("Expected first call to return .handled") + } + + if case .notHandled(.duplicateNavigation) = result2 { + // Success + } else { + XCTFail("Expected second call to return .duplicateNavigation") + } } - + @MainActor - func testHandleNavigationLoadsDuckPlayer() { - - let link = URL(string: "duck://player/12345")! - let navigationAction = MockNavigationAction(request: URLRequest(url: link)) - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + func testHandleURLChange_DuckPlayerURL_ReturnsHandled() async { + // Arrange + let duckPlayerURL = URL(string: "duck://player/abc123")! + mockWebView.setCurrentURL(duckPlayerURL) playerSettings.mode = .enabled - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - - handler.handleNavigation(navigationAction, webView: webView) - - let expectation = self.expectation(description: "Simulated Request Expectation") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - - XCTAssertEqual(self.webView.url?.absoluteString, "https://www.youtube-nocookie.com/embed/12345") - expectation.fulfill() + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + let result = handler.handleURLChange(webView: mockWebView) + + if case .notHandled(.isNotYoutubeWatch) = result { + // Success + } else { + XCTFail("Expected .unhandled for duck:// URLs") } - - waitForExpectations(timeout: 1.0, handler: nil) - } - + @MainActor - func testHandleNavigationWithDuckPlayerDisabledRedirectsToYoutube() { - - let link = URL(string: "duck://player/12345")! - let navigationAction = MockNavigationAction(request: URLRequest(url: link)) - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.mode = .disabled - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - - handler.handleNavigation(navigationAction, webView: webView) - - let expectation = self.expectation(description: "Youtube URL request") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - - guard let redirectedURL = self.webView.url, - let components = URLComponents(url: redirectedURL, resolvingAgainstBaseURL: false) else { - XCTFail("URL is missing or could not be parsed.") - expectation.fulfill() - return - } - - // Extract path and video ID from the redirected URL - let isWatchPath = components.path == "/watch" - let videoID = components.queryItems?.first(where: { $0.name == "v" })?.value - - XCTAssertTrue(isWatchPath, "Expected the path to be /watch.") - XCTAssertEqual(videoID, "12345", "Expected the video ID to match.") - expectation.fulfill() + func testHandleURLChange_NonYouTubeURL_ReturnsUnhandled() async { + // Arrange + let nonYouTubeURL = URL(string: "https://www.example.com")! + mockWebView.setCurrentURL(nonYouTubeURL) + playerSettings.mode = .enabled + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + let result = handler.handleURLChange(webView: mockWebView) + + // Assert + if case .notHandled(.invalidURL) = result { + // Success + } else { + XCTFail("Expected .unhandled for non-YouTube URL") } - - waitForExpectations(timeout: 1.0, handler: nil) } - + @MainActor - func testHandleReloadForDuckPlayerVideo() { - let duckPlayerURL = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! - - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.mode = .enabled - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - handler.handleReload(webView: mockWebView) - - if let loadedRequest = mockWebView.lastLoadedRequest { - XCTAssertEqual(loadedRequest.url?.scheme, "duck") - XCTAssertEqual(loadedRequest.url?.host, "player") - XCTAssertEqual(loadedRequest.url?.path, "/abc123") - XCTAssertEqual(loadedRequest.url?.query?.contains("t=10s"), true) + func testHandleURLChange_YouTubeURL_DuckPlayerModeDisabled_ReturnsUnhandled() async { + // Arrange + let youtubeURL = URL(string: "https://m.youtube.com/watch?v=abc123")! + mockWebView.setCurrentURL(youtubeURL) + playerSettings.mode = .disabled // DuckPlayer mode is disabled + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + let result = handler.handleURLChange(webView: mockWebView) + + // Assert + if case .notHandled(.duckPlayerDisabled) = result { + // Success + } else { + XCTFail("Expected .unhandled when DuckPlayer mode is disabled") } } - + @MainActor - func testAttach() { - let duckPlayerURL = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! - - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + func testHandleURLChange_YouTubeURL_FeatureFlagDisabled_ReturnsUnhandled() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + mockWebView.setCurrentURL(youtubeURL) playerSettings.mode = .enabled - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - handler.handleAttach(webView: mockWebView) - - if let loadedRequest = mockWebView.lastLoadedRequest { - XCTAssertEqual(loadedRequest.url?.scheme, "duck") - XCTAssertEqual(loadedRequest.url?.host, "player") - XCTAssertEqual(loadedRequest.url?.path, "/abc123") - XCTAssertEqual(loadedRequest.url?.query?.contains("t=10s"), true) + featureFlagger.enabledFeatures = [] // Feature is disabled + + // Act + let result = handler.handleURLChange(webView: mockWebView) + + // Assert + if case .notHandled(.featureOff) = result { + // Success + } else { + XCTFail("Expected .unhandled when feature flag is disabled") } } - func testGetURLForYoutubeNoCookieWithDuckPlayerEnabled() { - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + @MainActor + func testHandleDelegateNavigation_NotToMainFrame_ReturnsFalse() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + let request = URLRequest(url: youtubeURL) + let navigationAction = MockNavigationAction(request: request) playerSettings.mode = .enabled - - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - var url = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! - var duckURL = handler.getDuckURLFor(url).absoluteString - XCTAssertEqual(duckURL, "duck://player/abc123?t=10s") - - url = URL(string: "https://www.youtube.com/watch?v=I9J120SZT14")! - duckURL = handler.getDuckURLFor(url).absoluteString - XCTAssertEqual(duckURL, url.absoluteString) - + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + let shouldCancel = handler.handleDelegateNavigation(navigationAction: navigationAction, webView: mockWebView) + + // Assert + XCTAssertFalse(shouldCancel, "Expected navigation NOT to be cancelled as it's not mainframe navigation") } - func testGetURLForYoutubeNoCookieWithDuckPlayerAskMode() { - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - playerSettings.mode = .alwaysAsk - - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - var url = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! - var duckURL = handler.getDuckURLFor(url).absoluteString - XCTAssertEqual(duckURL, "duck://player/abc123?t=10s") - - url = URL(string: "https://www.youtube.com/watch?v=I9J120SZT14")! - duckURL = handler.getDuckURLFor(url).absoluteString - XCTAssertEqual(duckURL, url.absoluteString) - + @MainActor + func testHandleDelegateNavigation_With_DuckPlayerFeatureDisabled_ReturnsFalse() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + let request = URLRequest(url: youtubeURL) + let mockFrameInfo = MockFrameInfo(isMainFrame: true) + let navigationAction = MockNavigationAction(request: request, targetFrame: mockFrameInfo) + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + let shouldCancel = handler.handleDelegateNavigation(navigationAction: navigationAction, webView: mockWebView) + + // Assert + XCTAssertFalse(shouldCancel, "Expected navigation NOT be cancelled as DuckPlayer Feature is Disabled") } - func testGetURLForYoutubeNoCookieWithDuckPlayerDisabled() { - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) + + @MainActor + func testHandleDelegateNavigation_With_DuckPlayerDisabled_ReturnsFalse() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + let request = URLRequest(url: youtubeURL) + let mockFrameInfo = MockFrameInfo(isMainFrame: true) + let navigationAction = MockNavigationAction(request: request, targetFrame: mockFrameInfo) playerSettings.mode = .disabled - - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - var url = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! - var duckURL = handler.getDuckURLFor(url).absoluteString - XCTAssertEqual(duckURL, url.absoluteString) - - url = URL(string: "https://www.youtube.com/watch?v=I9J120SZT14")! - duckURL = handler.getDuckURLFor(url).absoluteString - XCTAssertEqual(duckURL, url.absoluteString) - + + // Act + let shouldCancel = handler.handleDelegateNavigation(navigationAction: navigationAction, webView: mockWebView) + + // Assert + XCTAssertFalse(shouldCancel, "Expected navigation NOT to be cancelled as DuckPlayer is Disabled") } - func testShouldOpenInNewTabWhenEnabled() { - let youtubeURL = URL(string: "duck://player/abc123")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - - mockAppSettings.duckPlayerOpenInNewTab = true - - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - - handler.navigationType = .linkActivated + @MainActor + func testHandleDelegateNavigation_ToYouTubeWith_DuckPlayerAlwaysAsk_ReturnsTrue() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + let request = URLRequest(url: youtubeURL) + let mockFrameInfo = MockFrameInfo(isMainFrame: true) + let navigationAction = MockNavigationAction(request: request, targetFrame: mockFrameInfo) + playerSettings.mode = .alwaysAsk + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + let shouldCancel = handler.handleDelegateNavigation(navigationAction: navigationAction, webView: mockWebView) + + // Assert + XCTAssertTrue(shouldCancel, "Expected navigation TO be cancelled as it should redirect to Youtube") + } + + @MainActor + func testHandleDelegateNavigation_WithBackForwardNavigation_ReturnsFalse() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + let request = URLRequest(url: youtubeURL) + let mockFrameInfo = MockFrameInfo(isMainFrame: true) + let navigationAction = MockNavigationAction(request: request, navigationType: .backForward, targetFrame: mockFrameInfo) playerSettings.mode = .enabled - - XCTAssertTrue(handler.shouldOpenInNewTab(navigationAction, webView: webView)) + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + let shouldCancel = handler.handleDelegateNavigation(navigationAction: navigationAction, webView: mockWebView) + + // Assert + XCTAssertFalse(shouldCancel, "Expected navigation to be cancelled as Nav is backForward") } - func testShouldNotOpenInNewTabWhenNotDuckPlayerURL() { - let youtubeURL = URL(string: "https://www.youtube.com/watch?v=I9J120SZT14")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) - - mockAppSettings.duckPlayerOpenInNewTab = true - - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - - handler.navigationType = .linkActivated + @MainActor + func testHandleDelegateNavigation_WithAllowFirstVideo_ReturnsFalse() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + let request = URLRequest(url: youtubeURL) + let mockFrameInfo = MockFrameInfo(isMainFrame: true) + let navigationAction = MockNavigationAction(request: request, targetFrame: mockFrameInfo) playerSettings.mode = .enabled - - XCTAssertFalse(handler.shouldOpenInNewTab(navigationAction, webView: webView)) + playerSettings.allowFirstVideo = true + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + let shouldCancel = handler.handleDelegateNavigation(navigationAction: navigationAction, webView: mockWebView) + + // Assert + XCTAssertFalse(shouldCancel, "Expected navigation to be cancelled as it's first video") } - func testShouldNotOpenInNewTabWhenDisabled() { - let youtubeURL = URL(string: "duck://player/abc123")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) + @MainActor + func testHandleDelegateNavigation_WithValidURL_ReturnsTrue() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + let request = URLRequest(url: youtubeURL) + let mockFrameInfo = MockFrameInfo(isMainFrame: true) + let navigationAction = MockNavigationAction(request: request, targetFrame: mockFrameInfo) + playerSettings.mode = .enabled + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + let shouldCancel = handler.handleDelegateNavigation(navigationAction: navigationAction, webView: mockWebView) + + // Assert + XCTAssertTrue(shouldCancel, "Expected navigation to be cancelled as it's first video") - mockAppSettings.duckPlayerOpenInNewTab = false + if let url = mockWebView.lastLoadedRequest?.url { + XCTAssertTrue(url.isDuckPlayer, "Expected final URL to be a Duck URL") + } else { + XCTFail("No URL was loaded. Expecting a Duck Player Video") + } - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - - handler.navigationType = .linkActivated + } + + + @MainActor + func testHandleDelegateNavigation_YouTubeURL_DuckPlayerDisabled_ReturnsFalse() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + let request = URLRequest(url: youtubeURL) + let navigationAction = MockNavigationAction(request: request) + playerSettings.mode = .disabled // DuckPlayer mode is disabled + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + + // Act + let shouldCancel = handler.handleDelegateNavigation(navigationAction: navigationAction, webView: mockWebView) + + // Assert + XCTAssertFalse(shouldCancel, "Expected navigation not to be cancelled when DuckPlayer is disabled") + } + + @MainActor + func testHandleDelegateNavigation_YouTubeURL_FeatureFlagDisabled_ReturnsFalse() async { + // Arrange + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + let request = URLRequest(url: youtubeURL) + let navigationAction = MockNavigationAction(request: request) playerSettings.mode = .enabled - - XCTAssertFalse(handler.shouldOpenInNewTab(navigationAction, webView: webView)) + featureFlagger.enabledFeatures = [] // Feature is disabled + + // Act + let shouldCancel = handler.handleDelegateNavigation(navigationAction: navigationAction, webView: mockWebView) + + // Assert + XCTAssertFalse(shouldCancel, "Expected navigation not to be cancelled when feature flag is disabled") } - func testHandleJSNavigationEventWhenEnabled() { - let youtubeURL = URL(string: "duck://player/abc123")! - - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - + @MainActor + func testHandleDelegateNavigation_DuckPlayerURL_DoesNotOpenInNewTabIfFeatureDisabled() async { + // Arrange + let duckPlayerURL = URL(string: "duck://player/abc123")! + let request = URLRequest(url: duckPlayerURL) + let mockFrameInfo = MockFrameInfo(isMainFrame: true) + let navigationAction = MockNavigationAction(request: request, targetFrame: mockFrameInfo) playerSettings.mode = .enabled - mockAppSettings.duckPlayerOpenInNewTab = true - - handler.handleEvent(event: .JSTriggeredNavigation, url: youtubeURL, navigationAction: nil) - - XCTAssertTrue(handler.navigationType == .linkActivated) + playerSettings.openInNewTab = true + featureFlagger.enabledFeatures = [.duckPlayer] // Duckplayer feature is disabled + + // Act + let shouldCancel = handler.handleDelegateNavigation(navigationAction: navigationAction, webView: mockWebView) + + // Assert + XCTAssertFalse(shouldCancel, "Expected navigation not to be cancelled for DuckPlayer URL") + XCTAssertNil(tabNavigator.openedURL, "No new tabs should open") } - func testHandleJSNavigationEventWhenDisabled() { - let youtubeURL = URL(string: "duck://player/abc123")! - - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - + + // MARK: Reload Operations + @MainActor + func testHandleDelegateNavigation_DuckPlayerURLReloads_DoesNotOpenInANewTab() async { + // Arrange + let duckPlayerURL = URL(string: "duck://player/abc123")! playerSettings.mode = .enabled - mockAppSettings.duckPlayerOpenInNewTab = false + playerSettings.openInNewTab = true + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + mockWebView.setCurrentURL(duckPlayerURL) - handler.handleEvent(event: .JSTriggeredNavigation, url: youtubeURL, navigationAction: nil) - - XCTAssertFalse(handler.navigationType == .linkActivated) + // Act + handler.handleReload(webView: mockWebView) + + // Assert + XCTAssertNil(tabNavigator.openedURL, "No new tabs should open") } - func testHandleJSNavigationEventWhenDuckPlayerDisabled() { - let youtubeURL = URL(string: "duck://player/abc123")! - - let playerSettings = MockDuckPlayerSettings(appSettings: mockAppSettings, privacyConfigManager: mockPrivacyConfig) - let player = MockDuckPlayer(settings: playerSettings, featureFlagger: featureFlagger) - let handler = DuckPlayerNavigationHandler(duckPlayer: player, featureFlagger: featureFlagger, appSettings: mockAppSettings, experiment: DuckPlayerExperimentMock()) - - handler.navigationType = .linkActivated - playerSettings.mode = .disabled - mockAppSettings.duckPlayerOpenInNewTab = true - - handler.handleEvent(event: .JSTriggeredNavigation, url: youtubeURL, navigationAction: nil) + @MainActor + func testHandleDelegateNavigation_YoutubeWatchURLWithAlwaysAsk_DoesNotOpenInANewTab() async { + // Arrange + let duckPlayerURL = URL(string: "https://www.youtube.com/watch?v=abc123")! + playerSettings.mode = .alwaysAsk + playerSettings.openInNewTab = true + featureFlagger.enabledFeatures = [.duckPlayer, .duckPlayerOpenInNewTab] + mockWebView.setCurrentURL(duckPlayerURL) - XCTAssertFalse(handler.navigationType == .linkActivated) - } + // Act + handler.handleReload(webView: mockWebView) + // Assert + XCTAssertNil(tabNavigator.openedURL, "No new tabs should open") + } + } From 0873a740c3d18a5fe50806bb463362f502029229 Mon Sep 17 00:00:00 2001 From: Thom Espach Date: Fri, 1 Nov 2024 12:24:10 +0000 Subject: [PATCH 09/29] Bug Fix: Phishing Detection Dataset Discrepancies (#3469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1204023833050360/1208567121137949/f Tech Design URL: CC: **Description**: **Steps to test this PR**: 1. No changes for iOS. **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) --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5fd3444f82..c3f2997410 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10970,7 +10970,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 202.1.0; + version = 202.2.0; }; }; 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 f182f09943..5d7145ade8 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" : "8a1bc5526e14c589ca2cc74e6e7d125952b79bc1", - "version" : "202.1.0" + "revision" : "d39d04cf36b8522f894eebc3e11ee5fe65d880fa", + "version" : "202.2.0" } }, { From cc7929eac26258a8d0127c9036be76a1b945424d Mon Sep 17 00:00:00 2001 From: Michal Smaga Date: Fri, 1 Nov 2024 13:31:06 +0100 Subject: [PATCH 10/29] Update to subscription cookie (#3512) Task/Issue URL: https://app.asana.com/0/1108686900785972/1208264562025859/f **Description**: Initial PR -> https://github.com/duckduckgo/iOS/pull/3488 Update to how the subscription cookie operates: - constraint the cookie to `subscriptions.duckduckgo.com` - on sign out do not fully remove the cookie - just clear the value - gate the feature behind the `setAccessTokenCookieForSubscriptionDomains` privacy config feature flag --- Core/PixelEvent.swift | 8 +-- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- DuckDuckGo/AppDelegate.swift | 56 +++++++++++++++---- ...riptionCookieManageEventPixelMapping.swift | 10 ++-- 5 files changed, 55 insertions(+), 25 deletions(-) diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 0a021242ea..4849cb9921 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -710,8 +710,8 @@ extension Pixel { case privacyProKeychainAccessError case privacyProSubscriptionCookieMissingTokenOnSignIn case privacyProSubscriptionCookieMissingCookieOnSignOut - case privacyProSubscriptionCookieRefreshedWithUpdate - case privacyProSubscriptionCookieRefreshedWithDelete + case privacyProSubscriptionCookieRefreshedWithAccessToken + case privacyProSubscriptionCookieRefreshedWithEmptyValue case privacyProSubscriptionCookieFailedToSetSubscriptionCookie // MARK: Pixel Experiment @@ -1527,8 +1527,8 @@ extension Pixel.Event { case .privacyProKeychainAccessError: return "m_privacy-pro_keychain_access_error" case .privacyProSubscriptionCookieMissingTokenOnSignIn: return "m_privacy-pro_subscription-cookie-missing_token_on_sign_in" case .privacyProSubscriptionCookieMissingCookieOnSignOut: return "m_privacy-pro_subscription-cookie-missing_cookie_on_sign_out" - case .privacyProSubscriptionCookieRefreshedWithUpdate: return "m_privacy-pro_subscription-cookie-refreshed_with_update" - case .privacyProSubscriptionCookieRefreshedWithDelete: return "m_privacy-pro_subscription-cookie-refreshed_with_delete" + case .privacyProSubscriptionCookieRefreshedWithAccessToken: return "m_privacy-pro_subscription-cookie-refreshed_with_access_token" + case .privacyProSubscriptionCookieRefreshedWithEmptyValue: return "m_privacy-pro_subscription-cookie-refreshed_with_empty_value" case .privacyProSubscriptionCookieFailedToSetSubscriptionCookie: return "m_privacy-pro_subscription-cookie-failed_to_set_subscription_cookie" // MARK: Pixel Experiment diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 1e414812e4..8251c8968f 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -11010,7 +11010,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 201.0.0; + version = "201.0.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 21aa8e0ac9..7541e05369 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" : "e5946eee6af859690cc1cc5e51daef3c8368981b", - "version" : "201.0.0" + "revision" : "9506581ae99273681073f9993fc6d881d3edaa7f", + "version" : "201.0.0-1" } }, { diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index d5f34b1a3c..b5448d7b67 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -89,6 +89,7 @@ import os.log private(set) var subscriptionFeatureAvailability: SubscriptionFeatureAvailability! private var subscriptionCookieManager: SubscriptionCookieManaging! + private var subscriptionCookieManagerFeatureFlagCancellable: AnyCancellable? var privacyProDataReporter: PrivacyProDataReporting! // MARK: - Feature specific app event handlers @@ -123,7 +124,7 @@ import os.log } } - // swiftlint:disable:next function_body_length cyclomatic_complexity + // swiftlint:disable:next function_body_length func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { #if targetEnvironment(simulator) @@ -313,17 +314,8 @@ import os.log subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability( privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, purchasePlatform: .appStore) - - subscriptionCookieManager = SubscriptionCookieManager(subscriptionManager: AppDependencyProvider.shared.subscriptionManager, - currentCookieStore: { [weak self] in - guard self?.mainViewController?.tabManager.model.hasActiveTabs ?? false else { - // We shouldn't interact with WebKit's cookie store unless we have a WebView, - // eventually the subscription cookie will be refreshed on opening the first tab - return nil - } - - return WKWebsiteDataStore.current().httpCookieStore - }, eventMapping: SubscriptionCookieManageEventPixelMapping()) + + subscriptionCookieManager = makeSubscriptionCookieManager() homePageConfiguration = HomePageConfiguration(variantManager: AppDependencyProvider.shared.variantManager, remoteMessagingClient: remoteMessagingClient, @@ -416,6 +408,46 @@ import os.log return true } + private func makeSubscriptionCookieManager() -> SubscriptionCookieManaging { + let subscriptionCookieManager = SubscriptionCookieManager(subscriptionManager: AppDependencyProvider.shared.subscriptionManager, + currentCookieStore: { [weak self] in + guard self?.mainViewController?.tabManager.model.hasActiveTabs ?? false else { + // We shouldn't interact with WebKit's cookie store unless we have a WebView, + // eventually the subscription cookie will be refreshed on opening the first tab + return nil + } + + return WKWebsiteDataStore.current().httpCookieStore + }, eventMapping: SubscriptionCookieManageEventPixelMapping()) + + + let privacyConfigurationManager = ContentBlocking.shared.privacyConfigurationManager + + // Enable subscriptionCookieManager if feature flag is present + if privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) { + subscriptionCookieManager.enableSettingSubscriptionCookie() + } + + // Keep track of feature flag changes + subscriptionCookieManagerFeatureFlagCancellable = privacyConfigurationManager.updatesPublisher + .sink { [weak self, weak privacyConfigurationManager] in + guard let self, let privacyConfigurationManager else { return } + + let isEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) + + Task { [weak self] in + if isEnabled { + self?.subscriptionCookieManager.enableSettingSubscriptionCookie() + await self?.subscriptionCookieManager.refreshSubscriptionCookie() + } else { + await self?.subscriptionCookieManager.disableSettingSubscriptionCookie() + } + } + } + + return subscriptionCookieManager + } + private func makeHistoryManager() -> HistoryManaging { let provider = AppDependencyProvider.shared diff --git a/DuckDuckGo/Subscription/SubscriptionCookieManageEventPixelMapping.swift b/DuckDuckGo/Subscription/SubscriptionCookieManageEventPixelMapping.swift index 19efdba9dd..0ec0ee2ef9 100644 --- a/DuckDuckGo/Subscription/SubscriptionCookieManageEventPixelMapping.swift +++ b/DuckDuckGo/Subscription/SubscriptionCookieManageEventPixelMapping.swift @@ -30,12 +30,10 @@ public final class SubscriptionCookieManageEventPixelMapping: EventMapping Date: Fri, 1 Nov 2024 13:45:58 +0100 Subject: [PATCH 11/29] Release 7.143.0-1 (#3516) Please make sure all GH checks passed before merging. It can take around 20 minutes. Briefly review this PR to see if there are no issues or red flags and then merge it. --- DuckDuckGo.xcodeproj/project.pbxproj | 56 ++++++++++++++-------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 8251c8968f..0662791490 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -9225,7 +9225,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -9262,7 +9262,7 @@ CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9352,7 +9352,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9379,7 +9379,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9528,7 +9528,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9553,7 +9553,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; INFOPLIST_FILE = DuckDuckGo/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9622,7 +9622,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -9656,7 +9656,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9689,7 +9689,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9719,7 +9719,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10029,7 +10029,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10060,7 +10060,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10088,7 +10088,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10121,7 +10121,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -10151,7 +10151,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProviderAlpha.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -10184,11 +10184,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10421,7 +10421,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10448,7 +10448,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10480,7 +10480,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10517,7 +10517,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10552,7 +10552,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10587,11 +10587,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10764,11 +10764,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10797,10 +10797,10 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; From 02c66cad6b81195e7f718c42f69625162d1fe89e Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Fri, 1 Nov 2024 17:08:09 +0100 Subject: [PATCH 12/29] Add a debouncer to NavBars animator (#3519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1204099484721401/1208671955053442/f Tech Design URL: CC: **Description**: Fixes a crash related to PDFs and scroll position --- DuckDuckGo/BarsAnimator.swift | 38 +++++++++++---- DuckDuckGoTests/BarsAnimatorTests.swift | 63 +++++++++++++++++++------ 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/DuckDuckGo/BarsAnimator.swift b/DuckDuckGo/BarsAnimator.swift index 01cae59724..c52e7201d7 100644 --- a/DuckDuckGo/BarsAnimator.swift +++ b/DuckDuckGo/BarsAnimator.swift @@ -87,18 +87,38 @@ class BarsAnimator { } private func transitioningAndScrolling(in scrollView: UIScrollView) { - let ratio = calculateTransitionRatio(for: scrollView.contentOffset.y) + + // On iOS 18 we end up in a loop after setBarsVisibility. + // It seems to trigger a new didScrollEvent when rendering some PDF files + // That causes an infinite loop. + // Are viewDidScroll calls happening more often for PDF's on iOS 18? + // Adding a debouncer while we investigate further + // https://app.asana.com/0/1204099484721401/1208671955053442/f + let debounceDelay: TimeInterval = 0.01 + struct Debounce { + static var workItem: DispatchWorkItem? + } + Debounce.workItem?.cancel() - if ratio == 1.0 { - barsState = .hidden - } else if ratio == 0 { - barsState = .revealed - } else if transitionProgress == ratio { - return + Debounce.workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + + let ratio = self.calculateTransitionRatio(for: scrollView.contentOffset.y) + + if ratio == 1.0 { + self.barsState = .hidden + } else if ratio == 0 { + self.barsState = .revealed + } else if self.transitionProgress == ratio { + return + } + + self.delegate?.setBarsVisibility(1.0 - ratio, animated: false) + self.transitionProgress = ratio } - delegate?.setBarsVisibility(1.0 - ratio, animated: false) - transitionProgress = ratio + // Schedule the work item + DispatchQueue.main.asyncAfter(deadline: .now() + debounceDelay, execute: Debounce.workItem!) } private func hiddenAndScrolling(in scrollView: UIScrollView) { diff --git a/DuckDuckGoTests/BarsAnimatorTests.swift b/DuckDuckGoTests/BarsAnimatorTests.swift index 566d0099aa..cbbd8da93f 100644 --- a/DuckDuckGoTests/BarsAnimatorTests.swift +++ b/DuckDuckGoTests/BarsAnimatorTests.swift @@ -51,10 +51,15 @@ class BarsAnimatorTests: XCTestCase { scrollView.contentOffset.y = 300 sut.didScroll(in: scrollView) - XCTAssertEqual(sut.barsState, .hidden) - - XCTAssertEqual(delegate.receivedMessages, [.setBarsVisibility(0.0), - .setBarsVisibility(0.0)]) + + let expectation = XCTestExpectation(description: "Wait for bars state to update to hidden") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(sut.barsState, .hidden) + XCTAssertEqual(delegate.receivedMessages, [.setBarsVisibility(0.0), .setBarsVisibility(0.0)]) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 0.2) } func testBarStateHiddenWhenScrollDownKeepsHiddenState() { @@ -71,14 +76,28 @@ class BarsAnimatorTests: XCTestCase { scrollView.contentOffset.y = 300 sut.didScroll(in: scrollView) - XCTAssertEqual(sut.barsState, .hidden) + + // Add delay before checking hidden state + let expectation1 = XCTestExpectation(description: "Wait for bars state to update to hidden") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(sut.barsState, .hidden) + expectation1.fulfill() + } + + wait(for: [expectation1], timeout: 0.2) scrollView.contentOffset.y = 100 sut.didScroll(in: scrollView) - XCTAssertEqual(sut.barsState, .hidden) - XCTAssertEqual(delegate.receivedMessages, [.setBarsVisibility(0.0), - .setBarsVisibility(0.0)]) + // Add another delay before checking that barsState remains hidden + let expectation2 = XCTestExpectation(description: "Wait to confirm bars state remains hidden") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(sut.barsState, .hidden) + XCTAssertEqual(delegate.receivedMessages, [.setBarsVisibility(0.0), .setBarsVisibility(0.0)]) + expectation2.fulfill() + } + + wait(for: [expectation2], timeout: 0.2) } func testBarStateHiddenWhenScrollUpUpdatesToRevealedState() { @@ -95,7 +114,15 @@ class BarsAnimatorTests: XCTestCase { scrollView.contentOffset.y = 400 sut.didScroll(in: scrollView) - XCTAssertEqual(sut.barsState, .hidden) + + // Add delay before checking hidden state + let expectation1 = XCTestExpectation(description: "Wait for bars state to update to hidden") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(sut.barsState, .hidden) + expectation1.fulfill() + } + + wait(for: [expectation1], timeout: 0.2) scrollView.contentOffset.y = -100 sut.didStartScrolling(in: scrollView) @@ -104,14 +131,22 @@ class BarsAnimatorTests: XCTestCase { scrollView.contentOffset.y = -150 sut.didScroll(in: scrollView) - XCTAssertEqual(sut.barsState, .revealed) - XCTAssertEqual(delegate.receivedMessages, [.setBarsVisibility(0.0), - .setBarsVisibility(0.0), - .setBarsVisibility(1.0), - .setBarsVisibility(1.0)]) + // Add another delay before checking revealed state + let expectation2 = XCTestExpectation(description: "Wait for bars state to update to revealed") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(sut.barsState, .revealed) + XCTAssertEqual(delegate.receivedMessages, [.setBarsVisibility(0.0), + .setBarsVisibility(0.0), + .setBarsVisibility(1.0), + .setBarsVisibility(1.0)]) + expectation2.fulfill() + } + + wait(for: [expectation2], timeout: 0.2) } + func testBarStateRevealedWhenScrollUpDoNotChangeCurrentState() { let (sut, delegate) = makeSUT() let scrollView = mockScrollView() From 62ef6faf34d33ff7bb6e5eeecb46ae27f9c5ac48 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Fri, 1 Nov 2024 18:37:17 +0100 Subject: [PATCH 13/29] Release 7.142.1-1 (#3525) --- Configuration/Version.xcconfig | 2 +- DuckDuckGo/Settings.bundle/Root.plist | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Configuration/Version.xcconfig b/Configuration/Version.xcconfig index 5a76c2767c..b1694385f9 100644 --- a/Configuration/Version.xcconfig +++ b/Configuration/Version.xcconfig @@ -1 +1 @@ -MARKETING_VERSION = 7.142.0 +MARKETING_VERSION = 7.142.1 diff --git a/DuckDuckGo/Settings.bundle/Root.plist b/DuckDuckGo/Settings.bundle/Root.plist index 549ce4019a..5e43b3b2c8 100644 --- a/DuckDuckGo/Settings.bundle/Root.plist +++ b/DuckDuckGo/Settings.bundle/Root.plist @@ -6,7 +6,7 @@ DefaultValue - 7.142.0 + 7.142.1 Key version Title From f29cd81a6c152f4309cb7200f734b4b0b2a3f297 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Nov 2024 18:42:14 +0100 Subject: [PATCH 14/29] Bump rexml from 3.3.8 to 3.3.9 (#3495) Bumps [rexml](https://github.com/ruby/rexml) from 3.3.8 to 3.3.9. --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index ca9c16ec9b..a2152bdba0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -174,7 +174,7 @@ GEM trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.3.8) + rexml (3.3.9) rouge (2.0.7) ruby2_keywords (0.0.5) rubyzip (2.3.2) From ad9dcab413ca9fa8dc047b87efab77c75333398a Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Fri, 1 Nov 2024 21:44:47 +0100 Subject: [PATCH 15/29] Update build number --- DuckDuckGo.xcodeproj/project.pbxproj | 56 ++++++++++++++-------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 0662791490..38dff56503 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -9225,7 +9225,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -9262,7 +9262,7 @@ CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9352,7 +9352,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9379,7 +9379,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9528,7 +9528,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9553,7 +9553,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; INFOPLIST_FILE = DuckDuckGo/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9622,7 +9622,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -9656,7 +9656,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9689,7 +9689,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9719,7 +9719,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10029,7 +10029,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10060,7 +10060,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10088,7 +10088,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10121,7 +10121,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -10151,7 +10151,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProviderAlpha.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -10184,11 +10184,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10421,7 +10421,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10448,7 +10448,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10480,7 +10480,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10517,7 +10517,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10552,7 +10552,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10587,11 +10587,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10764,11 +10764,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10797,10 +10797,10 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; From f0e950dd33aae762fc60f458c3201f318bbdd8c5 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Fri, 1 Nov 2024 21:44:47 +0100 Subject: [PATCH 16/29] Update build number --- DuckDuckGo.xcodeproj/project.pbxproj | 56 ++++++++++++++-------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 7ea408820d..65b6e48a25 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -9185,7 +9185,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -9222,7 +9222,7 @@ CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9312,7 +9312,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9339,7 +9339,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9488,7 +9488,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9513,7 +9513,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; INFOPLIST_FILE = DuckDuckGo/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9582,7 +9582,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -9616,7 +9616,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9649,7 +9649,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9679,7 +9679,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9989,7 +9989,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10020,7 +10020,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10048,7 +10048,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10081,7 +10081,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -10111,7 +10111,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProviderAlpha.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -10144,11 +10144,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10381,7 +10381,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10408,7 +10408,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10440,7 +10440,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10477,7 +10477,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10512,7 +10512,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10547,11 +10547,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10724,11 +10724,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10757,10 +10757,10 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; From 8c3379391582ccd99a66c69bfa5458d140f9f9b5 Mon Sep 17 00:00:00 2001 From: Tom Strba <57389842+tomasstrba@users.noreply.github.com> Date: Mon, 4 Nov 2024 00:05:48 +0100 Subject: [PATCH 17/29] Allowing users to delete suggestions on macOS (#3465) Task/Issue URL: https://app.asana.com/0/1148564399326804/1208219569168397/f Tech Design URL: CC: Description: This change is not related to iOS. We are adding and option to delete suggestions on macOS. --- Core/HistoryManager.swift | 4 ++++ DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 12 ++++++------ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Core/HistoryManager.swift b/Core/HistoryManager.swift index 0020468071..1fac8adb31 100644 --- a/Core/HistoryManager.swift +++ b/Core/HistoryManager.swift @@ -145,6 +145,10 @@ class NullHistoryCoordinator: HistoryCoordinating { completion() } + func removeUrlEntry(_ url: URL, completion: (((any Error)?) -> Void)?) { + completion?(nil) + } + } public class HistoryDatabase { diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 65b6e48a25..9e89867111 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10970,7 +10970,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 202.3.0; + version = 202.4.0; }; }; 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 2cbd2c54ae..0901332cc5 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" : "7b78b46340c9981b9774352be08c3c28b3a19011", - "version" : "202.3.0" + "revision" : "80894bf69fe789e41b13f3de6be97f1300ca56e5", + "version" : "202.4.0" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/privacy-dashboard", "state" : { - "revision" : "9de2b2aa317a48d3ee31116dc15b0feeb2cc9414", - "version" : "5.3.0" + "revision" : "53fd1a0f8d91fcf475d9220f810141007300dffd", + "version" : "7.1.1" } }, { @@ -176,8 +176,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/sync_crypto", "state" : { - "revision" : "2ab6ab6f0f96b259c14c2de3fc948935fc16ac78", - "version" : "0.2.0" + "revision" : "0c8bf3c0e75591bc366407b9d7a73a9fcfc7736f", + "version" : "0.3.0" } }, { From 4390003dbff67c371b76efd63daa7fe94e413d70 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 3 Nov 2024 16:17:38 -0800 Subject: [PATCH 18/29] Validate VPN errors before re-throwing them (#3513) Task/Issue URL: https://app.asana.com/0/414709148257752/1208225499545869/f Tech Design URL: CC: Description: This PR validates VPN errors before throwing them to the OS. --- Core/PixelEvent.swift | 4 ++++ DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- .../NetworkProtectionPacketTunnelProvider.swift | 4 ++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 47b48f1333..f300d9febc 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -460,6 +460,8 @@ extension Pixel { case networkProtectionConfigurationInvalidPayload(configuration: Configuration) case networkProtectionConfigurationPixelTest + case networkProtectionMalformedErrorDetected + // MARK: remote messaging pixels case remoteMessageShown @@ -1263,6 +1265,8 @@ extension Pixel.Event { case .networkProtectionConfigurationInvalidPayload(let config): return "m_netp_vpn_configuration_\(config.rawValue)_invalid_payload" case .networkProtectionConfigurationPixelTest: return "m_netp_vpn_configuration_pixel_test" + case .networkProtectionMalformedErrorDetected: return "m_netp_vpn_malformed_error_detected" + // MARK: remote messaging pixels case .remoteMessageShown: return "m_remote_message_shown" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 9e89867111..187e92d283 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10970,7 +10970,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 202.4.0; + version = 203.0.0; }; }; 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 0901332cc5..80a835b202 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" : "80894bf69fe789e41b13f3de6be97f1300ca56e5", - "version" : "202.4.0" + "revision" : "45261df2963fc89094e169f9f2d0d9aa098093f3", + "version" : "203.0.0" } }, { diff --git a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift index 0d15c548ad..fe424988e0 100644 --- a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift +++ b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift @@ -274,6 +274,10 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { vpnLogger.logStartingWithoutAuthToken() DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelStartAttemptOnDemandWithoutAccessToken, pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) + case .malformedErrorDetected(let error): + DailyPixel.fireDailyAndCount(pixel: .networkProtectionMalformedErrorDetected, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: error) } } From 556b858a0b0c07c152183b06c5dd52d1262ed38e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20=C5=9Apiewak?= Date: Mon, 4 Nov 2024 11:49:32 +0100 Subject: [PATCH 19/29] Add Privacy Config feature to control ad attribution reporting (#3506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/0/1208638248015576/f Tech Design URL: CC: **Description**: Adds the ability to use remote config to control `AdAttributionPixelReporter` and whether the token is added as parameter. **Steps to test this PR**: ⚠️ Device is required to fully test this change. Attribution is not available on simulator. 1. Modify remote config URL to `https://www.jsonblob.com/api/1301173210350215168`. Put app in the background and reactivate. 2. Verify attribution pixel is fired including token parameter. 4. Remove the app, change `includeToken` setting to `false` in the linked configuration json file or remove setting object completely, verify attribution pixel is fired without token parameter. 5. Turn off the feature in configuration json, verify no attribution pixel is fired. **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/FeatureFlag.swift | 3 + DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../AdAttributionPixelReporter.swift | 34 +++++++++-- DuckDuckGo/AppDelegate.swift | 2 - .../AdAttributionPixelReporterTests.swift | 58 ++++++++++++++++++- 6 files changed, 91 insertions(+), 12 deletions(-) diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index 3a63769bb9..b877485851 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -45,6 +45,7 @@ public enum FeatureFlag: String { case onboardingAddToDock case autofillSurveys case autcompleteTabs + case adAttributionReporting /// https://app.asana.com/0/72649045549333/1208231259093710/f case networkProtectionUserTips @@ -103,6 +104,8 @@ extension FeatureFlag: FeatureFlagSourceProviding { return .remoteReleasable(.feature(.autocompleteTabs)) case .networkProtectionUserTips: return .remoteReleasable(.subfeature(NetworkProtectionSubfeature.userTips)) + case .adAttributionReporting: + return .remoteReleasable(.feature(.adAttributionReporting)) } } } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 187e92d283..4a4cbaa73f 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10970,7 +10970,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 203.0.0; + version = 203.1.0; }; }; 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 80a835b202..0be6bd842d 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" : "45261df2963fc89094e169f9f2d0d9aa098093f3", - "version" : "203.0.0" + "revision" : "19f1e5c945aa92562ad2d087e8d6c99801edf656", + "version" : "203.1.0" } }, { diff --git a/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift b/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift index a09eb9d693..c5c5f8a3cd 100644 --- a/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift +++ b/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift @@ -19,28 +19,37 @@ import Foundation import Core +import BrowserServicesKit final actor AdAttributionPixelReporter { - - static let isAdAttributionReportingEnabled = false - + static var shared = AdAttributionPixelReporter() private var fetcherStorage: AdAttributionReporterStorage private let attributionFetcher: AdAttributionFetcher + private let featureFlagger: FeatureFlagger + private let privacyConfigurationManager: PrivacyConfigurationManaging private let pixelFiring: PixelFiringAsync.Type private var isSendingAttribution: Bool = false init(fetcherStorage: AdAttributionReporterStorage = UserDefaultsAdAttributionReporterStorage(), attributionFetcher: AdAttributionFetcher = DefaultAdAttributionFetcher(), + featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger, + privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager, pixelFiring: PixelFiringAsync.Type = Pixel.self) { self.fetcherStorage = fetcherStorage self.attributionFetcher = attributionFetcher self.pixelFiring = pixelFiring + self.featureFlagger = featureFlagger + self.privacyConfigurationManager = privacyConfigurationManager } @discardableResult func reportAttributionIfNeeded() async -> Bool { + guard featureFlagger.isFeatureOn(.adAttributionReporting) else { + return false + } + guard await fetcherStorage.wasAttributionReportSuccessful == false else { return false } @@ -57,7 +66,8 @@ final actor AdAttributionPixelReporter { if let (token, attributionData) = await self.attributionFetcher.fetch() { if attributionData.attribution { - let parameters = self.pixelParametersForAttribution(attributionData, attributionToken: token) + let settings = AdAttributionReporterSettings(privacyConfigurationManager.privacyConfig) + let parameters = self.pixelParametersForAttribution(attributionData, attributionToken: settings.includeToken ? token : nil) do { try await pixelFiring.fire( pixel: .appleAdAttribution, @@ -77,7 +87,7 @@ final actor AdAttributionPixelReporter { return false } - private func pixelParametersForAttribution(_ attribution: AdServicesAttributionResponse, attributionToken: String) -> [String: String] { + private func pixelParametersForAttribution(_ attribution: AdServicesAttributionResponse, attributionToken: String?) -> [String: String] { var params: [String: String] = [:] params[PixelParameters.adAttributionAdGroupID] = attribution.adGroupId.map(String.init) @@ -93,3 +103,17 @@ final actor AdAttributionPixelReporter { return params } } + +private struct AdAttributionReporterSettings { + var includeToken: Bool + + init(_ configuration: PrivacyConfiguration) { + let featureSettings = configuration.settings(for: .adAttributionReporting) + + self.includeToken = featureSettings[Key.includeToken] as? Bool ?? false + } + + private enum Key { + static let includeToken = "includeToken" + } +} diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index b5448d7b67..a585a0cbee 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -540,8 +540,6 @@ import os.log } private func reportAdAttribution() { - guard AdAttributionPixelReporter.isAdAttributionReportingEnabled else { return } - Task.detached(priority: .background) { await AdAttributionPixelReporter.shared.reportAttributionIfNeeded() } diff --git a/DuckDuckGoTests/AdAttributionPixelReporterTests.swift b/DuckDuckGoTests/AdAttributionPixelReporterTests.swift index 0a553d07b0..f8846346bd 100644 --- a/DuckDuckGoTests/AdAttributionPixelReporterTests.swift +++ b/DuckDuckGoTests/AdAttributionPixelReporterTests.swift @@ -26,15 +26,24 @@ final class AdAttributionPixelReporterTests: XCTestCase { private var attributionFetcher: AdAttributionFetcherMock! private var fetcherStorage: AdAttributionReporterStorageMock! + private var featureFlagger: MockFeatureFlagger! + private var privacyConfigurationManager: PrivacyConfigurationManagerMock! override func setUpWithError() throws { attributionFetcher = AdAttributionFetcherMock() fetcherStorage = AdAttributionReporterStorageMock() + featureFlagger = MockFeatureFlagger() + privacyConfigurationManager = PrivacyConfigurationManagerMock() + + featureFlagger.enabledFeatureFlags.append(.adAttributionReporting) } override func tearDownWithError() throws { attributionFetcher = nil fetcherStorage = nil + featureFlagger = nil + privacyConfigurationManager = nil + PixelFiringMock.tearDown() } @@ -59,7 +68,7 @@ final class AdAttributionPixelReporterTests: XCTestCase { XCTAssertFalse(result) } - func testPixelname() async { + func testPixelName() async { let sut = createSUT() attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) @@ -72,6 +81,7 @@ final class AdAttributionPixelReporterTests: XCTestCase { func testPixelAttributesNaming() async throws { let sut = createSUT() attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) + (privacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.settings[.adAttributionReporting] = ["includeToken": true] await sut.reportAttributionIfNeeded() @@ -157,9 +167,50 @@ final class AdAttributionPixelReporterTests: XCTestCase { XCTAssertFalse(result) } + func testDoesNotReportIfFeatureDisabled() async { + let sut = createSUT() + attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) + featureFlagger.enabledFeatureFlags = [] + + await fetcherStorage.markAttributionReportSuccessful() + let result = await sut.reportAttributionIfNeeded() + + XCTAssertNil(PixelFiringMock.lastPixelName) + XCTAssertFalse(result) + XCTAssertFalse(attributionFetcher.wasFetchCalled) + } + + func testDoesNotIncludeTokenWhenSettingMissing() async throws { + let sut = createSUT() + attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) + featureFlagger.enabledFeatureFlags = [.adAttributionReporting] + + await sut.reportAttributionIfNeeded() + + let pixelAttributes = try XCTUnwrap(PixelFiringMock.lastParams) + + XCTAssertNil(pixelAttributes["attribution_token"]) + } + + func testIncludesTokenWhenSettingEnabled() async throws { + let sut = createSUT() + attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) + featureFlagger.enabledFeatureFlags = [.adAttributionReporting] + + (privacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.settings[.adAttributionReporting] = ["includeToken": true] + + await sut.reportAttributionIfNeeded() + + let pixelAttributes = try XCTUnwrap(PixelFiringMock.lastParams) + + XCTAssertNotNil(pixelAttributes["attribution_token"]) + } + private func createSUT() -> AdAttributionPixelReporter { AdAttributionPixelReporter(fetcherStorage: fetcherStorage, attributionFetcher: attributionFetcher, + featureFlagger: featureFlagger, + privacyConfigurationManager: privacyConfigurationManager, pixelFiring: PixelFiringMock.self) } } @@ -173,9 +224,12 @@ class AdAttributionReporterStorageMock: AdAttributionReporterStorage { } class AdAttributionFetcherMock: AdAttributionFetcher { + var wasFetchCalled: Bool = false + var fetchResponse: (String, AdServicesAttributionResponse)? func fetch() async -> (String, AdServicesAttributionResponse)? { - fetchResponse + wasFetchCalled = true + return fetchResponse } } From 1c2abb9f69ebd76ebc3c45196bc7097fd286ac88 Mon Sep 17 00:00:00 2001 From: Michal Smaga Date: Mon, 4 Nov 2024 12:51:08 +0100 Subject: [PATCH 20/29] Release 7.144.0-0 (#3528) Please make sure all GH checks passed before merging. It can take around 20 minutes. Briefly review this PR to see if there are no issues or red flags and then merge it. --- Configuration/Version.xcconfig | 2 +- .../AppPrivacyConfigurationDataProvider.swift | 4 +- Core/ios-config.json | 238 ++++++++---------- DuckDuckGo.xcodeproj/project.pbxproj | 56 ++--- DuckDuckGo/Settings.bundle/Root.plist | 2 +- fastlane/README.md | 8 + 6 files changed, 141 insertions(+), 169 deletions(-) diff --git a/Configuration/Version.xcconfig b/Configuration/Version.xcconfig index e99711a2c3..40ae091e7d 100644 --- a/Configuration/Version.xcconfig +++ b/Configuration/Version.xcconfig @@ -1 +1 @@ -MARKETING_VERSION = 7.143.0 +MARKETING_VERSION = 7.144.0 diff --git a/Core/AppPrivacyConfigurationDataProvider.swift b/Core/AppPrivacyConfigurationDataProvider.swift index 15f2c36375..3036ae8a6a 100644 --- a/Core/AppPrivacyConfigurationDataProvider.swift +++ b/Core/AppPrivacyConfigurationDataProvider.swift @@ -23,8 +23,8 @@ import BrowserServicesKit final public class AppPrivacyConfigurationDataProvider: EmbeddedDataProvider { public struct Constants { - public static let embeddedDataETag = "\"f8b9cfd5f1eb7b77c21d4476f85bd177\"" - public static let embeddedDataSHA = "c26c97714d73a9e1e99dbd341d5890da42b49d34a296672be3d3cea00bdd37a0" + public static let embeddedDataETag = "\"516f95a16f7a556c58e14ee6f193cc30\"" + public static let embeddedDataSHA = "87314e1ac02784472a722844a27b443b0387a164ac72afaac00d9a70731fc572" } public var embeddedDataEtag: String { diff --git a/Core/ios-config.json b/Core/ios-config.json index edd16be36c..b7d7ecc0d0 100644 --- a/Core/ios-config.json +++ b/Core/ios-config.json @@ -1,6 +1,6 @@ { "readme": "https://github.com/duckduckgo/privacy-configuration", - "version": 1730109523334, + "version": 1730481067679, "features": { "adClickAttribution": { "readme": "https://help.duckduckgo.com/duckduckgo-help-pages/privacy/web-tracking-protections/#3rd-party-tracker-loading-protection", @@ -93,9 +93,6 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "settings": { @@ -122,7 +119,7 @@ ] }, "state": "enabled", - "hash": "d9703d9553194bc54e66db1f2a4ec1f8" + "hash": "fa5f86bac5946c528cd6bc7449a2718a" }, "androidBrowserConfig": { "exceptions": [], @@ -363,6 +360,36 @@ { "domain": "la-becanerie.com" }, + { + "domain": "thrifty.com" + }, + { + "domain": "dollar.com" + }, + { + "domain": "ethicalconsumer.org" + }, + { + "domain": "diroots.com" + }, + { + "domain": "arbeitsagentur.de" + }, + { + "domain": "melawear.de" + }, + { + "domain": "dnb.com" + }, + { + "domain": "bookings.ltmuseum.co.uk" + }, + { + "domain": "famillemary.fr" + }, + { + "domain": "manoloblahnik.com" + }, { "domain": "marvel.com" }, @@ -377,9 +404,6 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "settings": { @@ -407,7 +431,7 @@ } } }, - "hash": "8392e127a3bcaee5c2913df355a7d254" + "hash": "c2885a67db26958bdb316564d5c94878" }, "autofillBreakageReporter": { "state": "enabled", @@ -505,12 +529,15 @@ }, { "percent": 50 + }, + { + "percent": 100 } ] } } }, - "hash": "9de8e4b066aa23f7c20ca638ee0d9f1a" + "hash": "91e54b0d57fbf1cf8668c9a929631432" }, "bookmarks": { "state": "enabled", @@ -534,12 +561,9 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], - "hash": "3766f6af346d3fffdf1e8ffce682c66e" + "hash": "37e0cf88badfc8b01b6394f0884502f6" }, "brokenSitePrompt": { "state": "enabled", @@ -1243,9 +1267,6 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "settings": { @@ -1263,7 +1284,7 @@ } }, "state": "disabled", - "hash": "3973e9d924c9a054df7f5dffad1f1d19" + "hash": "cb1f114a9e0314393b2a0f789cba163f" }, "clickToPlay": { "exceptions": [ @@ -1281,9 +1302,6 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "settings": { @@ -1296,7 +1314,7 @@ } }, "state": "disabled", - "hash": "31a06101df1dc362bfcef2d7a6320f80" + "hash": "894fb86c1f058aee9db47cfcdf3637de" }, "clientBrandHint": { "exceptions": [], @@ -1340,16 +1358,13 @@ "domain": "flexmls.com" }, { - "domain": "humana.com" + "domain": "centerwellpharmacy.com" }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], - "hash": "980bf875526f3cc7892c001a7d2e5a74" + "hash": "1cc80acd10d985c950e40c5b876c581b" }, "contextualOnboarding": { "exceptions": [], @@ -1372,6 +1387,10 @@ { "domain": "payments.google.com", "reason": "After sign-in for Google Pay flows (after flickering is resolved), blocking this causes the loading spinner to spin indefinitely, and the payment flow cannot proceed." + }, + { + "domain": "docs.google.com", + "reason": "Embedded Google docs get into redirect loop if signed into a Google account" } ], "firstPartyTrackerCookiePolicy": { @@ -1419,13 +1438,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "disabled", - "hash": "cef2b67a9df0d36b0875e7b54d33a4d0" + "hash": "fce0a9ccd7ae060d25e7debe4d8905fb" }, "customUserAgent": { "settings": { @@ -1446,6 +1462,10 @@ { "domain": "ihg.com", "reason": "https://github.com/duckduckgo/privacy-configuration/pull/2383" + }, + { + "domain": "humana.com", + "reason": "https://github.com/duckduckgo/privacy-configuration/pull/2408" } ], "ddgDefaultSites": [ @@ -1475,7 +1495,7 @@ }, "exceptions": [], "state": "enabled", - "hash": "e577ccb473bdb7ada49c4d3c6e79cf01" + "hash": "345d837217e74afd3f9e5fd04b208fa7" }, "dbp": { "state": "disabled", @@ -1617,9 +1637,6 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "settings": { @@ -4169,6 +4186,15 @@ } ] }, + { + "domain": "salon.com", + "rules": [ + { + "selector": ".fc-ab-root", + "type": "hide" + } + ] + }, { "domain": "scmp.com", "rules": [ @@ -4939,7 +4965,7 @@ ] }, "state": "enabled", - "hash": "9518158b11d290809536a99f637f467e" + "hash": "d8fb8089fcfbd527940703c8e2665966" }, "exceptionHandler": { "exceptions": [ @@ -4957,13 +4983,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "disabled", - "hash": "a214254da3cc914ed5bfc0a2d893b589" + "hash": "be6751fe0307a7e1b9476f4d8b8d0aaf" }, "extendedOnboarding": { "exceptions": [], @@ -4990,12 +5013,9 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], - "hash": "008c61cd03c28287a7f86b598c37078b" + "hash": "7f042650922da2636492e77ed1101bce" }, "fingerprintingBattery": { "exceptions": [ @@ -5016,13 +5036,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "enabled", - "hash": "d05606a02ffd6ce5e223bc26e748a203" + "hash": "fcc2138fa97c35ded544b39708fda919" }, "fingerprintingCanvas": { "settings": { @@ -5127,13 +5144,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "disabled", - "hash": "b0eef1a098ab8c6cc9d6da35a9cfb7ad" + "hash": "49a3d497835bf5715aaaa73f87dd974f" }, "fingerprintingHardware": { "settings": { @@ -5199,13 +5213,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "enabled", - "hash": "25a38bd7ccbca83ce0899548608235a7" + "hash": "cd4a8461973d1c1648dd20e6d1f532a7" }, "fingerprintingScreenSize": { "settings": { @@ -5259,13 +5270,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "enabled", - "hash": "c22a6e9f1c03693516589c47970d7a04" + "hash": "046340bb9287a20efed6189525ec5fed" }, "fingerprintingTemporaryStorage": { "exceptions": [ @@ -5292,13 +5300,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "enabled", - "hash": "48b1d8e96ee94825378d12a8d5a66895" + "hash": "14b7fe3d276b52109c59f0c71aee4f71" }, "googleRejected": { "exceptions": [ @@ -5316,13 +5321,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "disabled", - "hash": "a214254da3cc914ed5bfc0a2d893b589" + "hash": "be6751fe0307a7e1b9476f4d8b8d0aaf" }, "gpc": { "state": "enabled", @@ -5371,9 +5373,6 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "settings": { @@ -5385,7 +5384,7 @@ "privacy-test-pages.site" ] }, - "hash": "37630ab090682ee7d004120a42031281" + "hash": "501bbc6471eb079cb27fa8a2a47467a5" }, "harmfulApis": { "settings": { @@ -5501,13 +5500,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "disabled", - "hash": "f255e336420119584b7000846be6d456" + "hash": "fb598c4167ff166d85dd49c701cc5579" }, "history": { "state": "enabled", @@ -5558,12 +5554,9 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], - "hash": "7407fc43cbd260f9aaca7cb7dab15bf4" + "hash": "b47d255c6f836ecb7ae0b3e61cc2c025" }, "incontextSignup": { "exceptions": [], @@ -5622,9 +5615,6 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "settings": { @@ -5635,7 +5625,7 @@ ] }, "state": "enabled", - "hash": "a1100eac5ecca0a11501df9f4dafa31a" + "hash": "d14f6e3a9aa4139ee1d517016b59691e" }, "networkProtection": { "state": "enabled", @@ -5682,13 +5672,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "disabled", - "hash": "5646a778c1cb6ec6e9c0da2c7dbd4bdb" + "hash": "82088db85ca7f64418fbfd57db25ade1" }, "performanceMetrics": { "state": "enabled", @@ -5707,12 +5694,9 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], - "hash": "60c3c3eed29e1e0c092fad8775483210" + "hash": "6792064606a5a72c5cd44addb4d40bda" }, "phishingDetection": { "state": "disabled", @@ -5731,12 +5715,9 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], - "hash": "3766f6af346d3fffdf1e8ffce682c66e" + "hash": "37e0cf88badfc8b01b6394f0884502f6" }, "pluginPointFocusedViewPlugin": { "state": "disabled", @@ -5853,13 +5834,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "disabled", - "hash": "68eb25a9461b134838100eecb0271905" + "hash": "138c3b2409f6b3bf967b804ab9bf2ce2" }, "remoteMessaging": { "state": "enabled", @@ -5883,15 +5861,12 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "settings": { "windowInMs": 0 }, - "hash": "13d2723b0c33943f086acb8c239e22e8" + "hash": "baf19d9e0f506ed09f46c95b1849adee" }, "runtimeChecks": { "state": "disabled", @@ -5910,13 +5885,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "settings": {}, - "hash": "568cf394681d38683d1aeb8f0d0e6a7c" + "hash": "dfede9f06b9e322e198736703d013d15" }, "sendFullPackageInstallSource": { "state": "enabled", @@ -5940,13 +5912,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "disabled", - "hash": "a214254da3cc914ed5bfc0a2d893b589" + "hash": "be6751fe0307a7e1b9476f4d8b8d0aaf" }, "sslCertificates": { "state": "enabled", @@ -5991,6 +5960,11 @@ "minSupportedVersion": "7.104.0", "hash": "d7dca6ee484eadebb5133e3f15fd9f41" }, + "textZoom": { + "exceptions": [], + "state": "enabled", + "hash": "52857469413a66e8b0c7b00de5589162" + }, "toggleReports": { "state": "enabled", "exceptions": [], @@ -6813,6 +6787,13 @@ "history.com" ] }, + { + "rule": "doubleclick.net/ondemand/dash/content/", + "domains": [ + "cbs.com", + "paramountplus.com" + ] + }, { "rule": "securepubads.g.doubleclick.net/gampad/ads", "domains": [ @@ -7425,7 +7406,8 @@ "piedmontng.com", "thesimsresource.com", "tradersync.com", - "vanguardplan.com" + "vanguardplan.com", + "xpn.org" ] } ] @@ -9363,12 +9345,9 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], - "hash": "434130223ee6493827d477d0171521da" + "hash": "c28128dee65a2aa7fef1528b73f33c7f" }, "trackingCookies1p": { "settings": { @@ -9392,13 +9371,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "disabled", - "hash": "a5c95510cb55fbe69cbff10e55a982dd" + "hash": "763f56424b0827b5731927a043219912" }, "trackingCookies3p": { "settings": { @@ -9419,13 +9395,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "disabled", - "hash": "5646a778c1cb6ec6e9c0da2c7dbd4bdb" + "hash": "82088db85ca7f64418fbfd57db25ade1" }, "trackingParameters": { "exceptions": [ @@ -9449,9 +9422,6 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "settings": { @@ -9484,7 +9454,7 @@ ] }, "state": "enabled", - "hash": "e530308726226930ff9a058fa064a39f" + "hash": "3805ecfb8a129f70a99e73a364b38f38" }, "userAgentRotation": { "settings": { @@ -9505,13 +9475,10 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "disabled", - "hash": "dd373ef0993c7ca9d9fa949db6d6aca0" + "hash": "9225b8785d6973db37abde99d81d219c" }, "voiceSearch": { "exceptions": [], @@ -9542,9 +9509,6 @@ }, { "domain": "instructure.com" - }, - { - "domain": "centerwellpharmacy.com" } ], "state": "enabled", @@ -9617,7 +9581,7 @@ } ] }, - "hash": "ed17f6ff342f200305eb4bbe544efec0" + "hash": "2853748f3ebb813d59f4db4a7bb13c83" }, "webViewBlobDownload": { "exceptions": [], diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 4a4cbaa73f..42d05b4f6c 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -9185,7 +9185,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -9222,7 +9222,7 @@ CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9312,7 +9312,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9339,7 +9339,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9488,7 +9488,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9513,7 +9513,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; INFOPLIST_FILE = DuckDuckGo/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9582,7 +9582,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -9616,7 +9616,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9649,7 +9649,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9679,7 +9679,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9989,7 +9989,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10020,7 +10020,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10048,7 +10048,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10081,7 +10081,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -10111,7 +10111,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProviderAlpha.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -10144,11 +10144,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 2; + DYLIB_CURRENT_VERSION = 0; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10381,7 +10381,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10408,7 +10408,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10440,7 +10440,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10477,7 +10477,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10512,7 +10512,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10547,11 +10547,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 2; + DYLIB_CURRENT_VERSION = 0; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10724,11 +10724,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 2; + DYLIB_CURRENT_VERSION = 0; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10757,10 +10757,10 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 0; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 2; + DYLIB_CURRENT_VERSION = 0; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; diff --git a/DuckDuckGo/Settings.bundle/Root.plist b/DuckDuckGo/Settings.bundle/Root.plist index a7f84961db..6e1c6ef73b 100644 --- a/DuckDuckGo/Settings.bundle/Root.plist +++ b/DuckDuckGo/Settings.bundle/Root.plist @@ -6,7 +6,7 @@ DefaultValue - 7.143.0 + 7.144.0 Key version Title diff --git a/fastlane/README.md b/fastlane/README.md index 195f7f9607..bb3195509a 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -69,6 +69,14 @@ Makes Ad-Hoc build with a specified name and release bundle ID in a given direct Makes Ad-Hoc build for alpha with a specified name and alpha bundle ID in a given directory +### promote_latest_testflight_to_appstore + +```sh +[bundle exec] fastlane promote_latest_testflight_to_appstore +``` + +Promotes the latest TestFlight build to App Store without submitting for review + ### release_appstore ```sh From 2b49550f541eefbea2d6dde1ce4df3cc78f03692 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Mon, 4 Nov 2024 14:30:53 +0100 Subject: [PATCH 21/29] Update release notes (#3529) Added release notes --- fastlane/metadata/default/release_notes.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/fastlane/metadata/default/release_notes.txt b/fastlane/metadata/default/release_notes.txt index 098fd1666f..a380910cf1 100644 --- a/fastlane/metadata/default/release_notes.txt +++ b/fastlane/metadata/default/release_notes.txt @@ -1 +1,2 @@ +- Videos in Duck Player now open in a new tab by default, making it easier to navigate between YouTube and Duck Player. This setting can also be turned off in Settings > Duck Player. - Bug fixes and other improvements. \ No newline at end of file From b1cb778da3e922f36632725018c5c45b8e1734ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20=C5=9Apiewak?= Date: Tue, 5 Nov 2024 13:42:25 +0100 Subject: [PATCH 22/29] Remove NewTabPage retain cycles (#3532) Task/Issue URL: https://app.asana.com/0/1206226850447395/1208686031091434/f Tech Design URL: CC: **Description**: Leak 1: `NewTabPageViewController` was not dismissed properly, this caused it to stay in memory as a child view controller of `MainViewController`. In effect it was possible to dismiss FaviconFetcherTutorial. Removing the leak required to do additional changes to make it work. I moved it to `MainViewController`, so that it's not dependent on NewTabPage. Leak 2: `NewTabPageSettingsModel` was leaking via strong reference present in `NTPSettingItem`. **Steps to test this PR**: 1. Set up Sync. 2. Add favorite. 3. On another synced device open New Tab Page 4. Favicon Tutorial should appear, see if buttons work, dismissing the tutorial 5. Open and close New Tab Page a few times. 6. Open Memory Graph Debugger, verify only single instance is present for `NewTabPageViewController` and `NewTabPageSettingsModel*` objects. **Definition of Done (Internal Only)**: * [ ] Does this PR satisfy our [Definition of Done](https://app.asana.com/0/1202500774821704/1207634633537039/f)? --- ###### Internal references: [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) --- DuckDuckGo/MainViewController.swift | 7 +++++-- DuckDuckGo/NewTabPageControllerDelegate.swift | 1 + DuckDuckGo/NewTabPageSettingsModel.swift | 14 +++++++------- DuckDuckGo/NewTabPageViewController.swift | 16 ++++++---------- .../NewTabPageControllerDaxDialogTests.swift | 11 +---------- 5 files changed, 20 insertions(+), 29 deletions(-) diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index d1a2fd2e36..14a110450f 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -128,6 +128,7 @@ class MainViewController: UIViewController { private lazy var featureFlagger = AppDependencyProvider.shared.featureFlagger private lazy var faviconLoader: FavoritesFaviconLoading = FavoritesFaviconLoader() + private lazy var faviconsFetcherOnboarding = FaviconsFetcherOnboarding(syncService: syncService, syncBookmarksAdapter: syncDataProviders.bookmarksAdapter) lazy var menuBookmarksViewModel: MenuBookmarksInteracting = { let viewModel = MenuBookmarksViewModel(bookmarksDatabase: bookmarksDatabase, syncService: syncService) @@ -795,8 +796,6 @@ class MainViewController: UIViewController { let controller = NewTabPageViewController(tab: tabModel, isNewTabPageCustomizationEnabled: homeTabManager.isNewTabPageSectionsEnabled, interactionModel: favoritesViewModel, - syncService: syncService, - syncBookmarksAdapter: syncDataProviders.bookmarksAdapter, homePageMessagesConfiguration: homePageConfiguration, privacyProDataReporting: privacyProDataReporter, variantManager: variantManager, @@ -2172,6 +2171,10 @@ extension MainViewController: NewTabPageControllerDelegate { func newTabPageDidDeleteFavorite(_ controller: NewTabPageViewController, favorite: BookmarkEntity) { // no-op for now } + + func newTabPageDidRequestFaviconsFetcherOnboarding(_ controller: NewTabPageViewController) { + faviconsFetcherOnboarding.presentOnboardingIfNeeded(from: self) + } } extension MainViewController: NewTabPageControllerShortcutsDelegate { diff --git a/DuckDuckGo/NewTabPageControllerDelegate.swift b/DuckDuckGo/NewTabPageControllerDelegate.swift index 08ecc46c65..d36758dc58 100644 --- a/DuckDuckGo/NewTabPageControllerDelegate.swift +++ b/DuckDuckGo/NewTabPageControllerDelegate.swift @@ -24,6 +24,7 @@ protocol NewTabPageControllerDelegate: AnyObject { func newTabPageDidOpenFavoriteURL(_ controller: NewTabPageViewController, url: URL) func newTabPageDidDeleteFavorite(_ controller: NewTabPageViewController, favorite: BookmarkEntity) func newTabPageDidEditFavorite(_ controller: NewTabPageViewController, favorite: BookmarkEntity) + func newTabPageDidRequestFaviconsFetcherOnboarding(_ controller: NewTabPageViewController) } protocol NewTabPageControllerShortcutsDelegate: AnyObject { diff --git a/DuckDuckGo/NewTabPageSettingsModel.swift b/DuckDuckGo/NewTabPageSettingsModel.swift index 9c36910613..6b9fbdec6b 100644 --- a/DuckDuckGo/NewTabPageSettingsModel.swift +++ b/DuckDuckGo/NewTabPageSettingsModel.swift @@ -81,15 +81,15 @@ final class NewTabPageSettingsModel, NewTabPage { - private let syncService: DDGSyncing - private let syncBookmarksAdapter: SyncBookmarksAdapter private let variantManager: VariantManager private let newTabDialogFactory: any NewTabDaxDialogProvider private let newTabDialogTypeProvider: NewTabDialogSpecProvider - private(set) lazy var faviconsFetcherOnboarding = FaviconsFetcherOnboarding(syncService: syncService, syncBookmarksAdapter: syncBookmarksAdapter) - private let newTabPageViewModel: NewTabPageViewModel private let messagesModel: NewTabPageMessagesModel private let favoritesModel: FavoritesViewModel @@ -53,8 +49,6 @@ final class NewTabPageViewController: UIHostingController, NewTabPage { init(tab: Tab, isNewTabPageCustomizationEnabled: Bool, interactionModel: FavoritesListInteracting, - syncService: DDGSyncing, - syncBookmarksAdapter: SyncBookmarksAdapter, homePageMessagesConfiguration: HomePageMessagesConfiguration, privacyProDataReporting: PrivacyProDataReporting? = nil, variantManager: VariantManager, @@ -63,8 +57,6 @@ final class NewTabPageViewController: UIHostingController, NewTabPage { faviconLoader: FavoritesFaviconLoading) { self.associatedTab = tab - self.syncService = syncService - self.syncBookmarksAdapter = syncBookmarksAdapter self.variantManager = variantManager self.newTabDialogFactory = newTabDialogFactory self.newTabDialogTypeProvider = newTabDialogTypeProvider @@ -145,7 +137,8 @@ final class NewTabPageViewController: UIHostingController, NewTabPage { private func assignFavoriteModelActions() { favoritesModel.onFaviconMissing = { [weak self] in guard let self else { return } - self.faviconsFetcherOnboarding.presentOnboardingIfNeeded(from: self) + + delegate?.newTabPageDidRequestFaviconsFetcherOnboarding(self) } favoritesModel.onFavoriteURLSelected = { [weak self] url in @@ -215,7 +208,10 @@ final class NewTabPageViewController: UIHostingController, NewTabPage { } func dismiss() { - + delegate = nil + chromeDelegate = nil + removeFromParent() + view.removeFromSuperview() } func showNextDaxDialog() { diff --git a/DuckDuckGoTests/NewTabPageControllerDaxDialogTests.swift b/DuckDuckGoTests/NewTabPageControllerDaxDialogTests.swift index 440a2934df..1c6322925e 100644 --- a/DuckDuckGoTests/NewTabPageControllerDaxDialogTests.swift +++ b/DuckDuckGoTests/NewTabPageControllerDaxDialogTests.swift @@ -38,14 +38,7 @@ final class NewTabPageControllerDaxDialogTests: XCTestCase { variantManager = CapturingVariantManager() dialogFactory = CapturingNewTabDaxDialogProvider() specProvider = MockNewTabDialogSpecProvider() - let dataProviders = SyncDataProviders( - bookmarksDatabase: db, - secureVaultFactory: AutofillSecureVaultFactory, - secureVaultErrorReporter: SecureVaultReporter(), - settingHandlers: [], - favoritesDisplayModeStorage: MockFavoritesDisplayModeStoring(), - syncErrorHandler: SyncErrorHandler() - ) + let remoteMessagingClient = RemoteMessagingClient( bookmarksDatabase: db, appSettings: AppSettingsMock(), @@ -60,8 +53,6 @@ final class NewTabPageControllerDaxDialogTests: XCTestCase { tab: Tab(), isNewTabPageCustomizationEnabled: false, interactionModel: MockFavoritesListInteracting(), - syncService: MockDDGSyncing(authState: .active, isSyncInProgress: false), - syncBookmarksAdapter: dataProviders.bookmarksAdapter, homePageMessagesConfiguration: homePageConfiguration, variantManager: variantManager, newTabDialogFactory: dialogFactory, From 66c516af2934ddff993cecb27b5a9c7a5b605cfa Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 5 Nov 2024 15:43:37 +0100 Subject: [PATCH 23/29] Send pixel on sync secure storage read failure (#3530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1201493110486074/1208686320819590/f **Description**: On investigating a hard-to-reproduce issue with sync, I noticed there's a gap in error reporting when the secure storage (keychain) is not available. This adds a pixel for that case. **Steps to test this PR**: Just a pixel in an error case. Hard to test without altering code. But if you do want to do that: 1. Enable sync 2. Change `BSK.DDGSync.SecureStorage.account()` to throw every time 3. Go to the Settings -> Sync screen 4. You should see the `sync_secure_storage_read_error` Pixel in the debug console **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/PixelEvent.swift | 2 ++ Core/SyncErrorHandler.swift | 2 ++ DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index f300d9febc..58518bbe63 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -624,6 +624,7 @@ extension Pixel { case syncRemoveDeviceError case syncDeleteAccountError case syncLoginExistingAccountError + case syncSecureStorageReadError case syncGetOtherDevices case syncGetOtherDevicesCopy @@ -1432,6 +1433,7 @@ extension Pixel.Event { case .syncRemoveDeviceError: return "m_d_sync_remove_device_error" case .syncDeleteAccountError: return "m_d_sync_delete_account_error" case .syncLoginExistingAccountError: return "m_d_sync_login_existing_account_error" + case .syncSecureStorageReadError: return "m_d_sync_secure_storage_error" case .syncGetOtherDevices: return "sync_get_other_devices" case .syncGetOtherDevicesCopy: return "sync_get_other_devices_copy" diff --git a/Core/SyncErrorHandler.swift b/Core/SyncErrorHandler.swift index a3ff07e794..93609732ba 100644 --- a/Core/SyncErrorHandler.swift +++ b/Core/SyncErrorHandler.swift @@ -100,6 +100,8 @@ public class SyncErrorHandler: EventMapping { Pixel.fire(pixel: .syncFailedToLoadAccount, error: error) case .failedToSetupEngine: Pixel.fire(pixel: .syncFailedToSetupEngine, error: error) + case .failedToReadSecureStore: + Pixel.fire(pixel: .syncSecureStorageReadError, error: error) default: // Should this be so generic? let domainEvent = Pixel.Event.syncSentUnauthenticatedRequest diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 42d05b4f6c..a72f2588fa 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10970,7 +10970,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 203.1.0; + version = 203.2.0; }; }; 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 0be6bd842d..2a7682eb00 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" : "19f1e5c945aa92562ad2d087e8d6c99801edf656", - "version" : "203.1.0" + "revision" : "56dbee74e34d37b6e699921a0b9bce2b8f22711d", + "version" : "203.2.0" } }, { From c5a97dda39e8ab854a4631be324567cd7034b16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20=C5=9Apiewak?= Date: Tue, 5 Nov 2024 15:51:37 +0100 Subject: [PATCH 24/29] UserDefaults misbehavior monitoring (#3510) Task/Issue URL: https://app.asana.com/0/1206226850447395/1208659072736427/f Tech Design URL: https://app.asana.com/0/481882893211075/1208618515043198/f CC: **Description**: Attempt to validate a hypothesis about unreliable/inaccessible UserDefaults data during app launch. **Steps to test this PR**: #### Statistics loader 1. Launch the app 2. Stop and put a breakpoint in `StatisticsLoader.swift:50` 3. Run the app again. On breakpoint run a debugger command: `expr statisticsStore.atb = nil` 4. Continue execution. 5. Verify proper pixel is fired. 6. On assertion go to `StatisticsLoader.load()` frame in the stack and run: `expr atbPresenceFileMarker?.unmark()` or remove the app. This will prevent assertion for next scenario. #### Ad attribution reporter 1. Enable `adAttributionReporting` feature flag. 1. Put a breakpoint in `AdAttributionPixelReporter.swift:60` 3. Run the app. On breakpoint run a debugger command: `expr attributionReportSuccessfulFileMarker?.mark()` 4. Continue execution 5. Verify proper pixel is fired. 6. On assertion go to `AdAttributionPixelReporter.reportAttributionIfNeeded()` frame in the stack and run: `expr attributionReportSuccessfulFileMarker?.unmark()` or remove the app. **Definition of Done (Internal Only)**: * [ ] Does this PR satisfy our [Definition of Done](https://app.asana.com/0/1202500774821704/1207634633537039/f)? --- ###### Internal references: [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) --- Core/BoolFileMarker.swift | 55 ++++++++++++++++ Core/BoolFileMarkerTests.swift | 58 ++++++++++++++++ Core/PixelEvent.swift | 10 +++ Core/StatisticsLoader.swift | 25 ++++++- Core/StorageInconsistencyMonitor.swift | 66 +++++++++++++++++++ DuckDuckGo.xcodeproj/project.pbxproj | 12 ++++ .../AdAttributionPixelReporter.swift | 52 +++++++++++++-- DuckDuckGo/AppDelegate.swift | 1 + .../AdAttributionPixelReporterTests.swift | 23 ++++++- DuckDuckGoTests/StatisticsLoaderTests.swift | 10 ++- IntegrationTests/AtbServerTests.swift | 10 ++- 11 files changed, 312 insertions(+), 10 deletions(-) create mode 100644 Core/BoolFileMarker.swift create mode 100644 Core/BoolFileMarkerTests.swift create mode 100644 Core/StorageInconsistencyMonitor.swift diff --git a/Core/BoolFileMarker.swift b/Core/BoolFileMarker.swift new file mode 100644 index 0000000000..d70c8f44cb --- /dev/null +++ b/Core/BoolFileMarker.swift @@ -0,0 +1,55 @@ +// +// BoolFileMarker.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. +// + +public struct BoolFileMarker { + let fileManager = FileManager.default + private let url: URL + + public var isPresent: Bool { + fileManager.fileExists(atPath: url.path) + } + + public func mark() { + if !isPresent { + fileManager.createFile(atPath: url.path, contents: nil, attributes: [.protectionKey: FileProtectionType.none]) + } + } + + public func unmark() { + if isPresent { + try? fileManager.removeItem(at: url) + } + } + + public init?(name: Name) { + guard let applicationSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + + self.url = applicationSupportDirectory.appendingPathComponent(name.rawValue) + } + + public struct Name: RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = "\(rawValue).marker" + } + } +} diff --git a/Core/BoolFileMarkerTests.swift b/Core/BoolFileMarkerTests.swift new file mode 100644 index 0000000000..23893a5668 --- /dev/null +++ b/Core/BoolFileMarkerTests.swift @@ -0,0 +1,58 @@ +// +// BoolFileMarkerTests.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 Core + +final class BoolFileMarkerTests: XCTestCase { + + private let marker = BoolFileMarker(name: .init(rawValue: "test"))! + + override func tearDown() { + super.tearDown() + + marker.unmark() + } + + private var testFileURL: URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("test.marker") + } + + func testMarkCreatesCorrectFile() throws { + + marker.mark() + + let fileURL = try XCTUnwrap(testFileURL) + + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + XCTAssertNil(attributes[.protectionKey]) + XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) + XCTAssertEqual(marker.isPresent, true) + } + + func testUnmarkRemovesFile() throws { + marker.mark() + marker.unmark() + + let fileURL = try XCTUnwrap(testFileURL) + + XCTAssertFalse(marker.isPresent) + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + } +} diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index f300d9febc..ed52ce6a71 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -835,6 +835,11 @@ extension Pixel { // MARK: WebView Error Page Shown case webViewErrorPageShown + + // MARK: UserDefaults incositency monitoring + case protectedDataUnavailableWhenBecomeActive + case statisticsLoaderATBStateMismatch + case adAttributionReportStateMismatch } } @@ -1666,6 +1671,11 @@ extension Pixel.Event { // MARK: - DuckPlayer FE Application Telemetry case .duckPlayerLandscapeLayoutImpressions: return "duckplayer_landscape_layout_impressions" + + // MARK: UserDefaults incositency monitoring + case .protectedDataUnavailableWhenBecomeActive: return "m_protected_data_unavailable_when_become_active" + case .statisticsLoaderATBStateMismatch: return "m_statistics_loader_atb_state_mismatch" + case .adAttributionReportStateMismatch: return "m_ad_attribution_report_state_mismatch" } } } diff --git a/Core/StatisticsLoader.swift b/Core/StatisticsLoader.swift index 38255c9097..a8001e6077 100644 --- a/Core/StatisticsLoader.swift +++ b/Core/StatisticsLoader.swift @@ -33,17 +33,29 @@ public class StatisticsLoader { private let returnUserMeasurement: ReturnUserMeasurement private let usageSegmentation: UsageSegmenting private let parser = AtbParser() + private let atbPresenceFileMarker = BoolFileMarker(name: .isATBPresent) + private let inconsistencyMonitoring: StatisticsStoreInconsistencyMonitoring init(statisticsStore: StatisticsStore = StatisticsUserDefaults(), returnUserMeasurement: ReturnUserMeasurement = KeychainReturnUserMeasurement(), - usageSegmentation: UsageSegmenting = UsageSegmentation()) { + usageSegmentation: UsageSegmenting = UsageSegmentation(), + inconsistencyMonitoring: StatisticsStoreInconsistencyMonitoring = StorageInconsistencyMonitor()) { self.statisticsStore = statisticsStore self.returnUserMeasurement = returnUserMeasurement self.usageSegmentation = usageSegmentation + self.inconsistencyMonitoring = inconsistencyMonitoring } public func load(completion: @escaping Completion = {}) { - if statisticsStore.hasInstallStatistics { + let hasFileMarker = atbPresenceFileMarker?.isPresent ?? false + let hasInstallStatistics = statisticsStore.hasInstallStatistics + + inconsistencyMonitoring.statisticsDidLoad(hasFileMarker: hasFileMarker, hasInstallStatistics: hasInstallStatistics) + + if hasInstallStatistics { + // Synchronize file marker with current state + createATBFileMarker() + completion() return } @@ -85,10 +97,15 @@ public class StatisticsLoader { self.statisticsStore.installDate = Date() self.statisticsStore.atb = atb.version self.returnUserMeasurement.installCompletedWithATB(atb) + self.createATBFileMarker() completion() } } + private func createATBFileMarker() { + atbPresenceFileMarker?.mark() + } + public func refreshSearchRetentionAtb(completion: @escaping Completion = {}) { guard let url = StatisticsDependentURLFactory(statisticsStore: statisticsStore).makeSearchAtbURL() else { requestInstallStatistics { @@ -169,3 +186,7 @@ public class StatisticsLoader { processUsageSegmentation(atb: nil, activityType: activityType) } } + +private extension BoolFileMarker.Name { + static let isATBPresent = BoolFileMarker.Name(rawValue: "atb-present") +} diff --git a/Core/StorageInconsistencyMonitor.swift b/Core/StorageInconsistencyMonitor.swift new file mode 100644 index 0000000000..d93cc4921c --- /dev/null +++ b/Core/StorageInconsistencyMonitor.swift @@ -0,0 +1,66 @@ +// +// StorageInconsistencyMonitor.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 + +public protocol AppActivationInconsistencyMonitoring { + /// See `StorageInconsistencyMonitor` for details + func didBecomeActive(isProtectedDataAvailable: Bool) +} + +public protocol StatisticsStoreInconsistencyMonitoring { + /// See `StorageInconsistencyMonitor` for details + func statisticsDidLoad(hasFileMarker: Bool, hasInstallStatistics: Bool) +} + +public protocol AdAttributionReporterInconsistencyMonitoring { + /// See `StorageInconsistencyMonitor` for details + func addAttributionReporter(hasFileMarker: Bool, hasCompletedFlag: Bool) +} + +/// Takes care of reporting inconsistency in storage availability and/or state. +/// See https://app.asana.com/0/481882893211075/1208618515043198/f for details. +public struct StorageInconsistencyMonitor: AppActivationInconsistencyMonitoring & StatisticsStoreInconsistencyMonitoring & AdAttributionReporterInconsistencyMonitoring { + + public init() { } + + /// Reports a pixel if data is not available while app is active + public func didBecomeActive(isProtectedDataAvailable: Bool) { + if !isProtectedDataAvailable { + Pixel.fire(pixel: .protectedDataUnavailableWhenBecomeActive) + assertionFailure("This is unexpected state, debug if possible") + } + } + + /// Reports a pixel if file marker exists but installStatistics are missing + public func statisticsDidLoad(hasFileMarker: Bool, hasInstallStatistics: Bool) { + if hasFileMarker == true && hasInstallStatistics == false { + Pixel.fire(pixel: .statisticsLoaderATBStateMismatch) + assertionFailure("This is unexpected state, debug if possible") + } + } + + /// Reports a pixel if file marker exists but completion flag is false + public func addAttributionReporter(hasFileMarker: Bool, hasCompletedFlag: Bool) { + if hasFileMarker == true && hasCompletedFlag == false { + Pixel.fire(pixel: .adAttributionReportStateMismatch) + assertionFailure("This is unexpected state, debug if possible") + } + } +} diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 42d05b4f6c..bed6f3b36b 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -297,12 +297,14 @@ 6F03CB052C32EFCC004179A8 /* MockPixelFiring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F03CB032C32EFA8004179A8 /* MockPixelFiring.swift */; }; 6F03CB072C32F173004179A8 /* PixelFiring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F03CB062C32F173004179A8 /* PixelFiring.swift */; }; 6F03CB092C32F331004179A8 /* PixelFiringAsync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F03CB082C32F331004179A8 /* PixelFiringAsync.swift */; }; + 6F04224D2CD2A3AD00729FA6 /* StorageInconsistencyMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F98573F2CD2933B001BE9A0 /* StorageInconsistencyMonitor.swift */; }; 6F0FEF6B2C516D540090CDE4 /* NewTabPageSettingsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0FEF6A2C516D540090CDE4 /* NewTabPageSettingsStorage.swift */; }; 6F0FEF6D2C52639E0090CDE4 /* ReorderableForEach.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0FEF6C2C52639E0090CDE4 /* ReorderableForEach.swift */; }; 6F35379E2C4AAF2E009F8717 /* NewTabPageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F35379D2C4AAF2E009F8717 /* NewTabPageSettingsView.swift */; }; 6F3537A02C4AAFD2009F8717 /* NewTabPageSettingsSectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F35379F2C4AAFD2009F8717 /* NewTabPageSettingsSectionItemView.swift */; }; 6F3537A22C4AB97A009F8717 /* NewTabPageSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3537A12C4AB97A009F8717 /* NewTabPageSettingsModel.swift */; }; 6F3537A42C4AC140009F8717 /* NewTabPageDaxLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3537A32C4AC140009F8717 /* NewTabPageDaxLogoView.swift */; }; + 6F395BBB2CD2C87D00B92FC3 /* BoolFileMarkerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F395BB92CD2C84300B92FC3 /* BoolFileMarkerTests.swift */; }; 6F40D15B2C34423800BF22F0 /* HomePageDisplayDailyPixelBucket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F40D15A2C34423800BF22F0 /* HomePageDisplayDailyPixelBucket.swift */; }; 6F40D15E2C34436500BF22F0 /* HomePageDisplayDailyPixelBucketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F40D15C2C34436200BF22F0 /* HomePageDisplayDailyPixelBucketTests.swift */; }; 6F5041C92CC11A5100989E48 /* SimpleNewTabPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F5041C82CC11A5100989E48 /* SimpleNewTabPageView.swift */; }; @@ -323,6 +325,7 @@ 6F8496412BC3D8EE00ADA54E /* OnboardingButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8496402BC3D8EE00ADA54E /* OnboardingButtonsView.swift */; }; 6F934F862C58DB00008364E4 /* NewTabPageSettingsPersistentStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F934F852C58DB00008364E4 /* NewTabPageSettingsPersistentStorageTests.swift */; }; 6F96FF102C2B128500162692 /* NewTabPageCustomizeButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F96FF0F2C2B128500162692 /* NewTabPageCustomizeButtonView.swift */; }; + 6F9857342CD27FA2001BE9A0 /* BoolFileMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9857332CD27F98001BE9A0 /* BoolFileMarker.swift */; }; 6F9FFE262C579BCD00A238BE /* NewTabPageShortcutsSettingsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9FFE252C579BCD00A238BE /* NewTabPageShortcutsSettingsStorage.swift */; }; 6F9FFE282C579DEA00A238BE /* NewTabPageSectionsSettingsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9FFE272C579DEA00A238BE /* NewTabPageSectionsSettingsStorage.swift */; }; 6F9FFE2A2C57ADB100A238BE /* EditableShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9FFE292C57ADB100A238BE /* EditableShortcutsView.swift */; }; @@ -1596,6 +1599,7 @@ 6F35379F2C4AAFD2009F8717 /* NewTabPageSettingsSectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageSettingsSectionItemView.swift; sourceTree = ""; }; 6F3537A12C4AB97A009F8717 /* NewTabPageSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageSettingsModel.swift; sourceTree = ""; }; 6F3537A32C4AC140009F8717 /* NewTabPageDaxLogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageDaxLogoView.swift; sourceTree = ""; }; + 6F395BB92CD2C84300B92FC3 /* BoolFileMarkerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoolFileMarkerTests.swift; sourceTree = ""; }; 6F40D15A2C34423800BF22F0 /* HomePageDisplayDailyPixelBucket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePageDisplayDailyPixelBucket.swift; sourceTree = ""; }; 6F40D15C2C34436200BF22F0 /* HomePageDisplayDailyPixelBucketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePageDisplayDailyPixelBucketTests.swift; sourceTree = ""; }; 6F5041C82CC11A5100989E48 /* SimpleNewTabPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleNewTabPageView.swift; sourceTree = ""; }; @@ -1616,6 +1620,8 @@ 6F8496402BC3D8EE00ADA54E /* OnboardingButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingButtonsView.swift; sourceTree = ""; }; 6F934F852C58DB00008364E4 /* NewTabPageSettingsPersistentStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageSettingsPersistentStorageTests.swift; sourceTree = ""; }; 6F96FF0F2C2B128500162692 /* NewTabPageCustomizeButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageCustomizeButtonView.swift; sourceTree = ""; }; + 6F9857332CD27F98001BE9A0 /* BoolFileMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoolFileMarker.swift; sourceTree = ""; }; + 6F98573F2CD2933B001BE9A0 /* StorageInconsistencyMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageInconsistencyMonitor.swift; sourceTree = ""; }; 6F9FFE252C579BCD00A238BE /* NewTabPageShortcutsSettingsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageShortcutsSettingsStorage.swift; sourceTree = ""; }; 6F9FFE272C579DEA00A238BE /* NewTabPageSectionsSettingsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageSectionsSettingsStorage.swift; sourceTree = ""; }; 6F9FFE292C57ADB100A238BE /* EditableShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableShortcutsView.swift; sourceTree = ""; }; @@ -5923,6 +5929,9 @@ F143C3191E4A99DD00CFDE3A /* Utilities */ = { isa = PBXGroup; children = ( + 6F98573F2CD2933B001BE9A0 /* StorageInconsistencyMonitor.swift */, + 6F9857332CD27F98001BE9A0 /* BoolFileMarker.swift */, + 6F395BB92CD2C84300B92FC3 /* BoolFileMarkerTests.swift */, 9FCFCD7D2C6AF52A006EB7A0 /* LaunchOptionsHandler.swift */, B603974829C19F6F00902A34 /* Assertions.swift */, CBAA195927BFE15600A4BD49 /* NSManagedObjectContextExtension.swift */, @@ -8018,6 +8027,7 @@ 310E79BD2949CAA5007C49E8 /* FireButtonReferenceTests.swift in Sources */, 4B62C4BA25B930DD008912C6 /* AppConfigurationFetchTests.swift in Sources */, 31C7D71C27515A6300A95D0A /* MockVoiceSearchHelper.swift in Sources */, + 6F395BBB2CD2C87D00B92FC3 /* BoolFileMarkerTests.swift in Sources */, 8598F67B2405EB8D00FBC70C /* KeyboardSettingsTests.swift in Sources */, 98AAF8E4292EB46000DBDF06 /* BookmarksMigrationTests.swift in Sources */, 85D2187224BF24F2004373D2 /* NotFoundCachingDownloaderTests.swift in Sources */, @@ -8253,6 +8263,7 @@ 9876B75E2232B36900D81D9F /* TabInstrumentation.swift in Sources */, 026DABA428242BC80089E0B5 /* MockUserAgent.swift in Sources */, 1E05D1D829C46EDA00BF9A1F /* TimedPixel.swift in Sources */, + 6F9857342CD27FA2001BE9A0 /* BoolFileMarker.swift in Sources */, C14882DC27F2011C00D59F0C /* BookmarksImporter.swift in Sources */, CBAA195A27BFE15600A4BD49 /* NSManagedObjectContextExtension.swift in Sources */, 37CBCA9E2A8A659C0050218F /* SyncSettingsAdapter.swift in Sources */, @@ -8328,6 +8339,7 @@ CB2A7EF128410DF700885F67 /* PixelEvent.swift in Sources */, 85D2187624BF6164004373D2 /* FaviconSourcesProvider.swift in Sources */, 98B000532915C46E0034BCA0 /* LegacyBookmarksStoreMigration.swift in Sources */, + 6F04224D2CD2A3AD00729FA6 /* StorageInconsistencyMonitor.swift in Sources */, 85200FA11FBC5BB5001AF290 /* DDGPersistenceContainer.swift in Sources */, 9FEA22322C3270BD006B03BF /* TimerInterface.swift in Sources */, 1E4DCF4C27B6A4CB00961E25 /* URLFileExtension.swift in Sources */, diff --git a/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift b/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift index c5c5f8a3cd..227ceeca0e 100644 --- a/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift +++ b/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift @@ -32,16 +32,32 @@ final actor AdAttributionPixelReporter { private let pixelFiring: PixelFiringAsync.Type private var isSendingAttribution: Bool = false + private let inconsistencyMonitoring: AdAttributionReporterInconsistencyMonitoring + private let attributionReportSuccessfulFileMarker = BoolFileMarker(name: .isAttrbutionReportSuccessful) + + private var shouldReport: Bool { + get async { + if let attributionReportSuccessfulFileMarker { + // If marker is present then report only if data consistent + return await !fetcherStorage.wasAttributionReportSuccessful && !attributionReportSuccessfulFileMarker.isPresent + } else { + return await fetcherStorage.wasAttributionReportSuccessful + } + } + } + init(fetcherStorage: AdAttributionReporterStorage = UserDefaultsAdAttributionReporterStorage(), attributionFetcher: AdAttributionFetcher = DefaultAdAttributionFetcher(), featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger, privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager, - pixelFiring: PixelFiringAsync.Type = Pixel.self) { + pixelFiring: PixelFiringAsync.Type = Pixel.self, + inconsistencyMonitoring: AdAttributionReporterInconsistencyMonitoring = StorageInconsistencyMonitor()) { self.fetcherStorage = fetcherStorage self.attributionFetcher = attributionFetcher - self.pixelFiring = pixelFiring self.featureFlagger = featureFlagger self.privacyConfigurationManager = privacyConfigurationManager + self.pixelFiring = pixelFiring + self.inconsistencyMonitoring = inconsistencyMonitoring } @discardableResult @@ -50,7 +66,9 @@ final actor AdAttributionPixelReporter { return false } - guard await fetcherStorage.wasAttributionReportSuccessful == false else { + await checkStorageConsistency() + + guard await shouldReport else { return false } @@ -79,7 +97,7 @@ final actor AdAttributionPixelReporter { } } - await fetcherStorage.markAttributionReportSuccessful() + await markAttributionReportSuccessful() return true } @@ -87,6 +105,28 @@ final actor AdAttributionPixelReporter { return false } + private func markAttributionReportSuccessful() async { + await fetcherStorage.markAttributionReportSuccessful() + attributionReportSuccessfulFileMarker?.mark() + } + + private func checkStorageConsistency() async { + + guard let attributionReportSuccessfulFileMarker else { return } + + let wasAttributionReportSuccessful = await fetcherStorage.wasAttributionReportSuccessful + + inconsistencyMonitoring.addAttributionReporter( + hasFileMarker: attributionReportSuccessfulFileMarker.isPresent, + hasCompletedFlag: wasAttributionReportSuccessful + ) + + // Synchronize file marker with current state (in case we have updated from previous version) + if wasAttributionReportSuccessful && !attributionReportSuccessfulFileMarker.isPresent { + attributionReportSuccessfulFileMarker.mark() + } + } + private func pixelParametersForAttribution(_ attribution: AdServicesAttributionResponse, attributionToken: String?) -> [String: String] { var params: [String: String] = [:] @@ -104,6 +144,10 @@ final actor AdAttributionPixelReporter { } } +private extension BoolFileMarker.Name { + static let isAttrbutionReportSuccessful = BoolFileMarker.Name(rawValue: "ad-attribution-successful") +} + private struct AdAttributionReporterSettings { var includeToken: Bool diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index a585a0cbee..52bc2e1ac9 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -548,6 +548,7 @@ import os.log func applicationDidBecomeActive(_ application: UIApplication) { guard !testing else { return } + StorageInconsistencyMonitor().didBecomeActive(isProtectedDataAvailable: application.isProtectedDataAvailable) syncService.initializeIfNeeded() syncDataProviders.setUpDatabaseCleanersIfNeeded(syncService: syncService) diff --git a/DuckDuckGoTests/AdAttributionPixelReporterTests.swift b/DuckDuckGoTests/AdAttributionPixelReporterTests.swift index f8846346bd..4e403893a5 100644 --- a/DuckDuckGoTests/AdAttributionPixelReporterTests.swift +++ b/DuckDuckGoTests/AdAttributionPixelReporterTests.swift @@ -29,6 +29,8 @@ final class AdAttributionPixelReporterTests: XCTestCase { private var featureFlagger: MockFeatureFlagger! private var privacyConfigurationManager: PrivacyConfigurationManagerMock! + private let fileMarker = BoolFileMarker(name: .init(rawValue: "ad-attribution-successful"))! + override func setUpWithError() throws { attributionFetcher = AdAttributionFetcherMock() fetcherStorage = AdAttributionReporterStorageMock() @@ -36,6 +38,7 @@ final class AdAttributionPixelReporterTests: XCTestCase { privacyConfigurationManager = PrivacyConfigurationManagerMock() featureFlagger.enabledFeatureFlags.append(.adAttributionReporting) + fileMarker.unmark() } override func tearDownWithError() throws { @@ -57,6 +60,17 @@ final class AdAttributionPixelReporterTests: XCTestCase { XCTAssertTrue(result) } + func testDoesNotReportIfOnlyFileMarkerIsPresent() async throws { + let sut = createSUT() + fileMarker.mark() + attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) + + let result = await sut.reportAttributionIfNeeded() + + XCTAssertNil(PixelFiringMock.lastPixelName) + XCTAssertFalse(result) + } + func testReportsOnce() async { let sut = createSUT() attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) @@ -211,7 +225,8 @@ final class AdAttributionPixelReporterTests: XCTestCase { attributionFetcher: attributionFetcher, featureFlagger: featureFlagger, privacyConfigurationManager: privacyConfigurationManager, - pixelFiring: PixelFiringMock.self) + pixelFiring: PixelFiringMock.self, + inconsistencyMonitoring: MockAdAttributionReporterInconsistencyMonitoring()) } } @@ -233,6 +248,12 @@ class AdAttributionFetcherMock: AdAttributionFetcher { } } +struct MockAdAttributionReporterInconsistencyMonitoring: AdAttributionReporterInconsistencyMonitoring { + func addAttributionReporter(hasFileMarker: Bool, hasCompletedFlag: Bool) { + + } +} + extension AdServicesAttributionResponse { init(attribution: Bool) { self.init( diff --git a/DuckDuckGoTests/StatisticsLoaderTests.swift b/DuckDuckGoTests/StatisticsLoaderTests.swift index 64dca2b495..6855f20b6c 100644 --- a/DuckDuckGoTests/StatisticsLoaderTests.swift +++ b/DuckDuckGoTests/StatisticsLoaderTests.swift @@ -34,7 +34,9 @@ class StatisticsLoaderTests: XCTestCase { mockStatisticsStore = MockStatisticsStore() mockUsageSegmentation = MockUsageSegmentation() - testee = StatisticsLoader(statisticsStore: mockStatisticsStore, usageSegmentation: mockUsageSegmentation) + testee = StatisticsLoader(statisticsStore: mockStatisticsStore, + usageSegmentation: mockUsageSegmentation, + inconsistencyMonitoring: MockStatisticsStoreInconsistencyMonitoring()) } override func tearDown() { @@ -304,3 +306,9 @@ class StatisticsLoaderTests: XCTestCase { } } + +private struct MockStatisticsStoreInconsistencyMonitoring: StatisticsStoreInconsistencyMonitoring { + func statisticsDidLoad(hasFileMarker: Bool, hasInstallStatistics: Bool) { + + } +} diff --git a/IntegrationTests/AtbServerTests.swift b/IntegrationTests/AtbServerTests.swift index 8d7a50a7bc..029e2bba34 100644 --- a/IntegrationTests/AtbServerTests.swift +++ b/IntegrationTests/AtbServerTests.swift @@ -34,8 +34,8 @@ class AtbServerTests: XCTestCase { super.setUp() store = MockStatisticsStore() - loader = StatisticsLoader(statisticsStore: store) - + loader = StatisticsLoader(statisticsStore: store, inconsistencyMonitoring: MockStatisticsStoreInconsistencyMonitoring()) + } func testExtiCall() { @@ -130,3 +130,9 @@ class MockStatisticsStore: StatisticsStore { var variant: String? } + +private struct MockStatisticsStoreInconsistencyMonitoring: StatisticsStoreInconsistencyMonitoring { + func statisticsDidLoad(hasFileMarker: Bool, hasInstallStatistics: Bool) { + + } +} From 53be32e8ad05547448dd2a8717392385b18f8447 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 5 Nov 2024 16:18:19 +0100 Subject: [PATCH 25/29] Release 7.144.0-1 (#3540) Please make sure all GH checks passed before merging. It can take around 20 minutes. Briefly review this PR to see if there are no issues or red flags and then merge it. --- DuckDuckGo.xcodeproj/project.pbxproj | 56 ++++++++++++++-------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index a72f2588fa..21ee89d124 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -9185,7 +9185,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -9222,7 +9222,7 @@ CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9312,7 +9312,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9339,7 +9339,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9488,7 +9488,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9513,7 +9513,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; INFOPLIST_FILE = DuckDuckGo/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9582,7 +9582,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -9616,7 +9616,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9649,7 +9649,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9679,7 +9679,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9989,7 +9989,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10020,7 +10020,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10048,7 +10048,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10081,7 +10081,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -10111,7 +10111,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProviderAlpha.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -10144,11 +10144,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10381,7 +10381,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10408,7 +10408,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10440,7 +10440,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10477,7 +10477,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10512,7 +10512,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10547,11 +10547,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10724,11 +10724,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10757,10 +10757,10 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 0; + DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; From 9005f0babff02e1051725ddf3683592e347f956f Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 5 Nov 2024 17:21:30 +0100 Subject: [PATCH 26/29] Change save password Never for Site button to Not Now (#3471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1202926619870900/1208592991532541/f **Description**: Change the Never Save button with Not Now on mobile platforms to avoid users accidentally disabling for their top sites on first usage. Update the logic accordingly. Send dismiss pixel on press of the Never Save button. **Steps to test this PR**: 1. Clear your autofill data from the debug menu 2. Go to https://fill.dev/form/login-simple and submit some details. 3. **Make sure there is no "Never Ask for This Site" button and there is a "Not Now" button** 4. Tap the "Not Now" button. 5. Repeat step 2. 6. **Make sure the Save prompt shows again** **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) --------- Co-authored-by: amddg44 --- DuckDuckGo/SaveLoginView.swift | 10 ++++++++-- DuckDuckGo/UserText.swift | 1 + DuckDuckGo/bg.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/cs.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/da.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/de.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/el.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/en.lproj/Localizable.strings | 3 +++ DuckDuckGo/es.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/et.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/fi.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/fr.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/hr.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/hu.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/it.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/lt.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/lv.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/nb.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/nl.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/pl.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/pt.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/ro.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/ru.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/sk.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/sl.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/sv.lproj/Localizable.strings | 6 ++++++ DuckDuckGo/tr.lproj/Localizable.strings | 8 +++++++- 27 files changed, 157 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/SaveLoginView.swift b/DuckDuckGo/SaveLoginView.swift index 9d15b185d4..2a738213d0 100644 --- a/DuckDuckGo/SaveLoginView.swift +++ b/DuckDuckGo/SaveLoginView.swift @@ -194,9 +194,15 @@ struct SaveLoginView: View { VStack(spacing: Const.Size.ctaVerticalSpacing) { AutofillViews.PrimaryButton(title: confirmButton, action: viewModel.save) + switch layoutType { + case .newUser: + AutofillViews.TertiaryButton(title: UserText.autofillSaveLoginNoThanksCTA, + action: viewModel.cancelButtonPressed) + default: + AutofillViews.TertiaryButton(title: UserText.autofillSaveLoginNeverPromptCTA, + action: viewModel.neverPrompt) + } - AutofillViews.TertiaryButton(title: UserText.autofillSaveLoginNeverPromptCTA, - action: viewModel.neverPrompt) } } diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 331dc45c98..15bef3837f 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -419,6 +419,7 @@ public struct UserText { public static let autofillOnboardingKeyFeaturesSecureStorageDescription = NSLocalizedString("autofill.onboarding.key-features.secure-storage.description", value: "Passwords are encrypted, stored on device, and locked with Face ID or passcode.", comment: "Description of autofill onboarding prompt's secure storage feature") public static let autofillOnboardingKeyFeaturesSyncTitle = NSLocalizedString("autofill.onboarding.key-features.sync.title", value: "Sync between devices", comment: "Title of autofill onboarding prompt's sync feature") public static let autofillOnboardingKeyFeaturesSyncDescription = NSLocalizedString("autofill.onboarding.key-features.sync.description", value: "End-to-end encrypted and easy to set up when you’re ready.", comment: "Description of autofill onboarding prompt's sync feature") + public static let autofillSaveLoginNoThanksCTA = NSLocalizedString("autofill.save-login.no-thanks.CTA", value: "No Thanks", comment: "CTA displayed on modal asking if the user wants to dismiss the save login action for now") public static let autofillSavePasswordSaveCTA = NSLocalizedString("autofill.save-password.save.CTA", value: "Save Password", comment: "Confirm CTA displayed on modal asking for the user to save the password") public static let autofillUpdatePasswordSaveCTA = NSLocalizedString("autofill.update-password.save.CTA", value: "Update Password", comment: "Confirm CTA displayed on modal asking for the user to update the password") diff --git a/DuckDuckGo/bg.lproj/Localizable.strings b/DuckDuckGo/bg.lproj/Localizable.strings index c83ff1b255..fe5c65f295 100644 --- a/DuckDuckGo/bg.lproj/Localizable.strings +++ b/DuckDuckGo/bg.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Търсене на пароли"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Паролите са криптирани. Никой освен Вас не може да ги види, дори ние."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Паролите са криптирани. Никой освен Вас не може да ги види, дори ние. [Научете повече](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Запазване на тази парола?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Не, благодаря"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "С DuckDuckGo Passwords & Autofill можете да съхранявате паролата по сигурен начин на устройството."; diff --git a/DuckDuckGo/cs.lproj/Localizable.strings b/DuckDuckGo/cs.lproj/Localizable.strings index 129c113c73..45df7910f4 100644 --- a/DuckDuckGo/cs.lproj/Localizable.strings +++ b/DuckDuckGo/cs.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Prohledat hesla"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Hesla jsou šifrovaná. Nikdo kromě tebe je nevidí, dokonce ani my."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Hesla jsou šifrovaná. Nikdo kromě tebe je nevidí, dokonce ani my. [Další informace](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Uložit tohle heslo?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Ne, děkuji"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Bezpečně si ulož heslo do zařízení pomocí funkce pro ukládání a automatické vyplňování hesel DuckDuckGo."; diff --git a/DuckDuckGo/da.lproj/Localizable.strings b/DuckDuckGo/da.lproj/Localizable.strings index 4813d438e4..382ee89485 100644 --- a/DuckDuckGo/da.lproj/Localizable.strings +++ b/DuckDuckGo/da.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Søg adgangskoder"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Adgangskoderne er krypterede. Ingen andre end dig kan se dem, ikke engang os."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Adgangskoderne er krypterede. Ingen andre end dig kan se dem, ikke engang os. [Få flere oplysninger](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Gem denne adgangskode?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Nej tak."; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Gem din adgangskode sikkert på enheden med DuckDuckGo Passwords & Autofill."; diff --git a/DuckDuckGo/de.lproj/Localizable.strings b/DuckDuckGo/de.lproj/Localizable.strings index 9c0f7ef2f1..5344dede4c 100644 --- a/DuckDuckGo/de.lproj/Localizable.strings +++ b/DuckDuckGo/de.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Passwörter suchen"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Passwörter sind verschlüsselt. Niemand außer dir kann sie sehen, nicht einmal wir."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Passwörter sind verschlüsselt. Niemand außer dir kann sie sehen, nicht einmal wir. [Mehr erfahren](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Dieses Passwort speichern?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Nein, danke"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Speichere dein Passwort mit DuckDuckGo Passwörter & Autovervollständigen sicher auf dem Gerät."; diff --git a/DuckDuckGo/el.lproj/Localizable.strings b/DuckDuckGo/el.lproj/Localizable.strings index 97567e39db..70c05970bd 100644 --- a/DuckDuckGo/el.lproj/Localizable.strings +++ b/DuckDuckGo/el.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Αναζήτηση κωδικών πρόσβασης"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Οι κωδικοί πρόσβασης είναι κρυπτογραφημένοι. Κανείς άλλος εκτός από εσάς δεν μπορεί να τους βλέπει, ούτε καν εμείς."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Οι κωδικοί πρόσβασης είναι κρυπτογραφημένοι. Κανείς άλλος εκτός από εσάς δεν μπορεί να τους βλέπει, ούτε καν εμείς. [Μάθετε περισσότερα](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Αποθήκευση αυτού του κωδικού πρόσβασης;"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Όχι, ευχαριστώ"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Αποθηκεύστε με ασφάλεια τον κωδικό πρόσβασής σας στη συσκευή με τη λειτουργία DuckDuckGo κωδικοί πρόσβασης και αυτόματη συμπλήρωση."; diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index b4b69e4eb4..2ff7e7ca21 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -593,6 +593,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Save this password?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "No Thanks"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Securely store your password on device with DuckDuckGo Passwords & Autofill."; diff --git a/DuckDuckGo/es.lproj/Localizable.strings b/DuckDuckGo/es.lproj/Localizable.strings index 85f5cff2a9..0b7bd2e9e0 100644 --- a/DuckDuckGo/es.lproj/Localizable.strings +++ b/DuckDuckGo/es.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Buscar contraseñas"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Las contraseñas están cifradas. Nadie más que tú puede verlas, ni siquiera nosotros."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Las contraseñas están cifradas. Nadie más que tú puede verlas, ni siquiera nosotros. [Más información](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "¿Guardar esta contraseña?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "No, gracias"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Almacena de forma segura tu contraseña en el dispositivo con DuckDuckGo Contraseñas y Autocompletar."; diff --git a/DuckDuckGo/et.lproj/Localizable.strings b/DuckDuckGo/et.lproj/Localizable.strings index f161bdab3f..38a5acaea0 100644 --- a/DuckDuckGo/et.lproj/Localizable.strings +++ b/DuckDuckGo/et.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Otsi paroole"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Paroolid on krüpteeritud. Keegi peale sinu ei näe neid, isegi mitte meie."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Paroolid on krüpteeritud. Keegi peale sinu ei näe neid, isegi mitte meie. [Lisateave](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Kas salvestada see parool?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Ei aitäh"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Salvesta oma parool turvaliselt seadmesse DuckDuckGo paroolide ja automaatse täitmisega."; diff --git a/DuckDuckGo/fi.lproj/Localizable.strings b/DuckDuckGo/fi.lproj/Localizable.strings index 9667b6d878..d0c31a4164 100644 --- a/DuckDuckGo/fi.lproj/Localizable.strings +++ b/DuckDuckGo/fi.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Etsi salasanoja"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Salasanat salataan. Kukaan muu kuin sinä ei näe niitä, emme edes me."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Salasanat salataan. Kukaan muu kuin sinä ei näe niitä, emme edes me. [Lisätietoja](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Tallennetaanko tämä salasana?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Ei kiitos"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Tallenna salasanasi turvallisesti laitteeseen DuckDuckGon salasanojen ja automaattisen täytön avulla."; diff --git a/DuckDuckGo/fr.lproj/Localizable.strings b/DuckDuckGo/fr.lproj/Localizable.strings index a4797bc797..f900fa4815 100644 --- a/DuckDuckGo/fr.lproj/Localizable.strings +++ b/DuckDuckGo/fr.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Rechercher un mot de passe"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Les mots de passe sont cryptés. Personne d'autre que vous ne peut les voir, pas même nous."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Les mots de passe sont cryptés. Personne d'autre que vous ne peut les voir, pas même nous. [En savoir plus](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Enregistrer ce mot de passe ?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Non merci"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Stockez votre mot de passe en toute sécurité sur votre appareil avec DuckDuckGo, Mots de passe et saisie automatique."; diff --git a/DuckDuckGo/hr.lproj/Localizable.strings b/DuckDuckGo/hr.lproj/Localizable.strings index bbbc5ada7a..269e54b844 100644 --- a/DuckDuckGo/hr.lproj/Localizable.strings +++ b/DuckDuckGo/hr.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Pretraživanje lozinki"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Lozinke su šifrirane. Nitko osim tebe ne može ih vidjeti, čak ni mi."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Lozinke su šifrirane. Nitko osim tebe ne može ih vidjeti, čak ni mi. [Saznaj više](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Želiš li spremiti ovu lozinku?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Ne, hvala"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Sigurno pohrani svoju lozinku na uređaj pomoću usluge automatskog popunjavanja DuckDuckGo Passwords & Autofill."; diff --git a/DuckDuckGo/hu.lproj/Localizable.strings b/DuckDuckGo/hu.lproj/Localizable.strings index 541241f5d6..52618f6a75 100644 --- a/DuckDuckGo/hu.lproj/Localizable.strings +++ b/DuckDuckGo/hu.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Jelszavak keresése"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "A jelszavak titkosítva vannak. Rajtad kívül senki sem láthatja őket, még mi sem."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "A jelszavak titkosítva vannak. Rajtad kívül senki sem láthatja őket, még mi sem. [További információk](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Mented a jelszót?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Nem, köszönöm"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Tárold biztonságosan a jelszavaidat az eszközödön a DuckDuckGo Jelszavak és automatikus kitöltés funkciójával."; diff --git a/DuckDuckGo/it.lproj/Localizable.strings b/DuckDuckGo/it.lproj/Localizable.strings index 4cffed59af..e01a9d279f 100644 --- a/DuckDuckGo/it.lproj/Localizable.strings +++ b/DuckDuckGo/it.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Cerca password"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Le password sono crittografate. Nessuno tranne te può vederle, nemmeno noi."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Le password sono crittografate. Nessuno tranne te può vederle, nemmeno noi. [Scopri di più](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Salvare questa password?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "No, grazie"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Memorizza in modo sicuro la tua password sul dispositivo con DuckDuckGo Passwords & Autofill."; diff --git a/DuckDuckGo/lt.lproj/Localizable.strings b/DuckDuckGo/lt.lproj/Localizable.strings index bad7486d4d..c116362c61 100644 --- a/DuckDuckGo/lt.lproj/Localizable.strings +++ b/DuckDuckGo/lt.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Ieškoti slaptažodžių"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Slaptažodžiai yra užšifruoti. Niekas, išskyrus jus, negali jų matyti – net mes."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Slaptažodžiai yra užšifruoti. Niekas, išskyrus jus, negali jų matyti – net mes. [Sužinoti daugiau](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Išsaugoti šį slaptažodį?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Ne, dėkoju"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Saugiai išsaugokite slaptažodį įrenginyje naudodami „DuckDuckGo“ slaptažodžių ir automatinio pildymo parinktį."; diff --git a/DuckDuckGo/lv.lproj/Localizable.strings b/DuckDuckGo/lv.lproj/Localizable.strings index 397c81bfcb..8f9c980cc1 100644 --- a/DuckDuckGo/lv.lproj/Localizable.strings +++ b/DuckDuckGo/lv.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Meklēt paroles"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Paroles ir šifrētas. Neviens, izņemot tevi, tās nevar redzēt – pat mēs ne."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Paroles ir šifrētas. Neviens, izņemot tevi, tās nevar redzēt – pat mēs ne. [Uzzini vairāk](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Vai saglabāt šo paroli?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Nē, paldies"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Droši saglabā paroli ierīcē, izmantojot DuckDuckGo paroles un automātisko aizpildīšanu."; diff --git a/DuckDuckGo/nb.lproj/Localizable.strings b/DuckDuckGo/nb.lproj/Localizable.strings index 985493c5f8..cd0ded480c 100644 --- a/DuckDuckGo/nb.lproj/Localizable.strings +++ b/DuckDuckGo/nb.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Søk i passord"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Passord krypteres. Ingen andre enn du kan se dem, ikke engang vi."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Passord krypteres. Ingen andre enn du kan se dem, ikke engang vi. [Finn ut mer](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Vil du lagre dette passordet?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Nei takk"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Lagre passordet ditt trygt på enheten med DuckDuckGos passord og autofyll."; diff --git a/DuckDuckGo/nl.lproj/Localizable.strings b/DuckDuckGo/nl.lproj/Localizable.strings index a710d2848d..0ae46ad07e 100644 --- a/DuckDuckGo/nl.lproj/Localizable.strings +++ b/DuckDuckGo/nl.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Wachtwoorden zoeken"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Wachtwoorden worden versleuteld. Niemand anders kan ze zien, zelfs wij niet."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Wachtwoorden worden versleuteld. Niemand anders dan jij kunt ze zien, zelfs wij niet. [Meer informatie](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Dit wachtwoord opslaan?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Nee, bedankt"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Sla je wachtwoord veilig op je apparaat op met DuckDuckGo wachtwoorden en automatisch invullen."; diff --git a/DuckDuckGo/pl.lproj/Localizable.strings b/DuckDuckGo/pl.lproj/Localizable.strings index 183724755d..9888d7bfe0 100644 --- a/DuckDuckGo/pl.lproj/Localizable.strings +++ b/DuckDuckGo/pl.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Wyszukaj hasła"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Hasła są szyfrowane. Nikt poza Tobą ich nie widzi, nawet my."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Hasła są szyfrowane. Nikt poza Tobą ich nie widzi, nawet my. [Więcej informacji] (DDGQuickLink: //duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Zapisać to hasło?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Nie, dziękuję"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Bezpiecznie przechowuj swoje hasło na urządzeniu dzięki funkcji Hasła i autouzupełnianie DuckDuckGo."; diff --git a/DuckDuckGo/pt.lproj/Localizable.strings b/DuckDuckGo/pt.lproj/Localizable.strings index d70ed96b69..7fd8ef149b 100644 --- a/DuckDuckGo/pt.lproj/Localizable.strings +++ b/DuckDuckGo/pt.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Pesquisar palavras-passe"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "As palavras-passe estão encriptadas. Ninguém além de ti pode vê-las, nem mesmo nós."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "As palavras-passe estão encriptadas. Ninguém além de ti pode vê-las, nem mesmo nós. [Sabe mais](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Guardar esta palavra-passe?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Não, obrigado"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Guarda a tua palavra-passe com segurança no dispositivo com a funcionalidade Palavras-passe e preenchimento automático do DuckDuckGo."; diff --git a/DuckDuckGo/ro.lproj/Localizable.strings b/DuckDuckGo/ro.lproj/Localizable.strings index a86f184e7b..ee562a26d3 100644 --- a/DuckDuckGo/ro.lproj/Localizable.strings +++ b/DuckDuckGo/ro.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Caută parole"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Parolele sunt criptate. Nimeni în afară de tine nu le poate vedea, nici măcar noi."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Parolele sunt criptate. Nimeni în afară de tine nu le poate vedea, nici măcar noi. [Află mai multe](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Dorești să salvezi această parolă?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Nu, mulțumesc"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Stochează în siguranță parola pe dispozitiv, cu Parole și completare automată DuckDuckGo."; diff --git a/DuckDuckGo/ru.lproj/Localizable.strings b/DuckDuckGo/ru.lproj/Localizable.strings index 065fa8a045..5d041fb58f 100644 --- a/DuckDuckGo/ru.lproj/Localizable.strings +++ b/DuckDuckGo/ru.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Найти пароль"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Пароли подвергаются шифрованию. Никто, кроме вас, их не увидит. Даже мы."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Пароли подвергаются шифрованию. Никто, кроме вас, их не увидит. Даже мы. [Подробнее...](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Сохранить пароль?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Нет, спасибо"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Вы можете сохранить этот пароль на устройстве под надежной защитой функции «Пароли и автозаполнение» от DuckDuckGo."; diff --git a/DuckDuckGo/sk.lproj/Localizable.strings b/DuckDuckGo/sk.lproj/Localizable.strings index 2f75da82d3..43c3005757 100644 --- a/DuckDuckGo/sk.lproj/Localizable.strings +++ b/DuckDuckGo/sk.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Vyhľadávanie hesiel"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Heslá sú zašifrované. Nikto okrem vás ich nemôže vidieť, dokonca ani my."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Heslá sú zašifrované. Nikto okrem vás ich nemôže vidieť, dokonca ani my. [Viac informácií](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Uložiť toto heslo?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Nie, ďakujem"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Heslo bezpečne uložte do zariadenia pomocou aplikácie DuckDuckGo Passwords & Autofill."; diff --git a/DuckDuckGo/sl.lproj/Localizable.strings b/DuckDuckGo/sl.lproj/Localizable.strings index 1d67ca1159..088ce79181 100644 --- a/DuckDuckGo/sl.lproj/Localizable.strings +++ b/DuckDuckGo/sl.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Iskanje gesel"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Gesla so šifrirana. Nihče razen vas jih ne more videti, niti mi."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Gesla so šifrirana. Nihče razen vas jih ne more videti, niti mi. [Več o tem](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Želite shraniti to geslo?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Ne, hvala"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "S funkcijo DuckDuckGo Passwords & Autofill varno shranite geslo v napravo."; diff --git a/DuckDuckGo/sv.lproj/Localizable.strings b/DuckDuckGo/sv.lproj/Localizable.strings index c847aaf2d1..de228cd093 100644 --- a/DuckDuckGo/sv.lproj/Localizable.strings +++ b/DuckDuckGo/sv.lproj/Localizable.strings @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Sök lösenord"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Lösenorden är krypterade. Ingen annan än du kan se dem, inte ens vi."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Lösenorden är krypterade. Ingen annan än du kan se dem, inte ens vi. [Läs mer](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Spara det här lösenordet?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Nej tack"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "Förvara ditt lösenord säkert på enheten med DuckDuckGo Lösenord och autofyll."; diff --git a/DuckDuckGo/tr.lproj/Localizable.strings b/DuckDuckGo/tr.lproj/Localizable.strings index de8681a50b..5b967dc65a 100644 --- a/DuckDuckGo/tr.lproj/Localizable.strings +++ b/DuckDuckGo/tr.lproj/Localizable.strings @@ -428,7 +428,7 @@ "autofill.logins.empty-view.button.title" = "Şifreleri İçe Aktar"; /* Subtitle for view displayed when no autofill passwords have been saved */ -"autofill.logins.empty-view.subtitle.first.paragraph" = "Kayıtlı parolaları başka bir tarayıcıdan DuckDuckGo'ya aktarabilirsiniz."; +"autofill.logins.empty-view.subtitle.first.paragraph" = "Kayıtlı parolaları başka bir tarayıcıdan DuckDuckGo'ya aktarabilirsiniz."; /* Title for view displayed when autofill has no items */ "autofill.logins.empty-view.title" = "Henüz şifre kaydedilmedi"; @@ -472,6 +472,9 @@ /* Placeholder for search field on autofill login listing */ "autofill.logins.list.search-placeholder" = "Şifreleri ara"; +/* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. */ +"autofill.logins.list.settings.footer.fallback" = "Parolalar şifrelenir. Onları sizden başka kimse göremez. Biz bile."; + /* Subtext under Autofill Settings briefly explaining security to alleviate user concerns. Has a URL link by clicking Learn More. */ "autofill.logins.list.settings.footer.markdown" = "Parolalar şifrelenir. Onları sizden başka kimse göremez. Biz bile. [Daha Fazla Bilgi](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/)"; @@ -596,6 +599,9 @@ /* Title displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.new-user.title" = "Bu parola kaydedilsin mi?"; +/* CTA displayed on modal asking if the user wants to dismiss the save login action for now */ +"autofill.save-login.no-thanks.CTA" = "Hayır Teşekkürler"; + /* Message displayed on modal asking for the user to save the login for the first time */ "autofill.save-login.security.message" = "DuckDuckGo Parolalar ve Otomatik Doldurma ile parolanızı cihazınızda güvenle saklayın."; From eb72d5429bdf2aca1ac737770527c8c203d10007 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Tue, 5 Nov 2024 17:39:19 +0100 Subject: [PATCH 27/29] Update C-S-S to 6.29.0 (#3541) Task/Issue URL: https://app.asana.com/0/1201048563534612/1208699974934565/f Description: This C-S-S release adds code for macOS. There are no changes to iOS. --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index bed6f3b36b..31a0150a02 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10982,7 +10982,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 203.1.0; + version = 203.3.0; }; }; 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 0be6bd842d..b8f119becf 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" : "19f1e5c945aa92562ad2d087e8d6c99801edf656", - "version" : "203.1.0" + "revision" : "64a5d8d1e19951fe397305a14e521713fb0eaa49", + "version" : "203.3.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "48fee2508995d4ac02d18b3d55424adedcb4ce4f", - "version" : "6.28.0" + "revision" : "6cab7bdb584653a5dc007cc1ae827ec41c5a91bc", + "version" : "6.29.0" } }, { From b50b7fa93417d6296a8788d9222a0db1e3c7a31b Mon Sep 17 00:00:00 2001 From: Alessandro Boron Date: Tue, 5 Nov 2024 17:51:26 +0100 Subject: [PATCH 28/29] Onboarding Add to Dock Refactor for Intro scenario (#3538) Task/Issue URL: https://app.asana.com/0/72649045549333/1208648960421864/f **Description**: Refactor the logic to show Add to Dock from the Onboarding Intro or the end of the contextual flow. --- Core/UserDefaultsPropertyWrapper.swift | 1 - DuckDuckGo.xcodeproj/project.pbxproj | 4 + DuckDuckGo/AppSettings.swift | 2 +- DuckDuckGo/AppUserDefaults.swift | 12 +- DuckDuckGo/DaxDialogs.swift | 13 ++- DuckDuckGo/MainViewController.swift | 2 +- DuckDuckGo/NewTabPageViewController.swift | 5 +- DuckDuckGo/OnboardingDebugView.swift | 52 ++++++++- .../AddToDock/AddToDockTutorialView.swift | 6 +- .../OnboardingView+AddToDockContent.swift | 96 ++++++++++++++++ .../ContextualOnboardingDialogs.swift | 17 ++- .../NewTabDaxDialogFactory.swift | 13 ++- .../ContextualDaxDialogsFactory.swift | 13 ++- .../Manager/OnboardingManager.swift | 35 ++++-- .../OnboardingIntroViewModel.swift | 20 +++- .../OnboardingIntro/OnboardingView.swift | 13 +++ DuckDuckGo/UserText.swift | 7 ++ DuckDuckGo/en.lproj/Localizable.strings | 12 ++ DuckDuckGoTests/AppSettingsMock.swift | 4 +- .../ContextualDaxDialogsFactoryTests.swift | 36 +++++- ...alOnboardingNewTabDialogFactoryTests.swift | 40 ++++++- DuckDuckGoTests/DaxDialogTests.swift | 50 ++++++++- .../OnboardingIntroViewModelTests.swift | 30 +++++ DuckDuckGoTests/OnboardingManagerMock.swift | 3 +- DuckDuckGoTests/OnboardingManagerTests.swift | 104 +++++++++++++----- .../TabViewControllerDaxDialogTests.swift | 1 + 26 files changed, 516 insertions(+), 75 deletions(-) create mode 100644 DuckDuckGo/OnboardingExperiment/AddToDock/OnboardingView+AddToDockContent.swift diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 0f625c97e3..1231840c9b 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -171,7 +171,6 @@ public struct UserDefaultsWrapper { // Debug keys case debugNewTabPageSectionsEnabledKey = "com.duckduckgo.ios.debug.newTabPageSectionsEnabled" case debugOnboardingHighlightsEnabledKey = "com.duckduckgo.ios.debug.onboardingHighlightsEnabled" - case debugOnboardingAddToDockEnabledKey = "com.duckduckgo.ios.debug.onboardingAddToDockEnabled" // Duck Player Pixel Experiment case duckPlayerPixelExperimentInstalled = "com.duckduckgo.ios.duckplayer.pixel.experiment.installed.v2" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 31a0150a02..34ac5cf757 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -713,6 +713,7 @@ 9F23B8032C2BCD0000950875 /* DaxDialogStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8022C2BCD0000950875 /* DaxDialogStyles.swift */; }; 9F23B8062C2BE22700950875 /* OnboardingIntroViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8052C2BE22700950875 /* OnboardingIntroViewModelTests.swift */; }; 9F23B8092C2BE9B700950875 /* MockURLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8082C2BE9B700950875 /* MockURLOpener.swift */; }; + 9F46BEF82CD8D7490092E0EF /* OnboardingView+AddToDockContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F46BEF72CD8D7490092E0EF /* OnboardingView+AddToDockContent.swift */; }; 9F4CC5152C47AD08006A96EB /* ContextualOnboardingPresenterMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4CC5142C47AD08006A96EB /* ContextualOnboardingPresenterMock.swift */; }; 9F4CC5172C48B8D4006A96EB /* TabViewControllerDaxDialogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4CC5162C48B8D4006A96EB /* TabViewControllerDaxDialogTests.swift */; }; 9F4CC51B2C48C0C7006A96EB /* MockTabDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4CC51A2C48C0C7006A96EB /* MockTabDelegate.swift */; }; @@ -2514,6 +2515,7 @@ 9F23B8022C2BCD0000950875 /* DaxDialogStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaxDialogStyles.swift; sourceTree = ""; }; 9F23B8052C2BE22700950875 /* OnboardingIntroViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingIntroViewModelTests.swift; sourceTree = ""; }; 9F23B8082C2BE9B700950875 /* MockURLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLOpener.swift; sourceTree = ""; }; + 9F46BEF72CD8D7490092E0EF /* OnboardingView+AddToDockContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnboardingView+AddToDockContent.swift"; sourceTree = ""; }; 9F4CC5142C47AD08006A96EB /* ContextualOnboardingPresenterMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingPresenterMock.swift; sourceTree = ""; }; 9F4CC5162C48B8D4006A96EB /* TabViewControllerDaxDialogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabViewControllerDaxDialogTests.swift; sourceTree = ""; }; 9F4CC51A2C48C0C7006A96EB /* MockTabDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTabDelegate.swift; sourceTree = ""; }; @@ -4810,6 +4812,7 @@ 9F8E0F302CCA6390001EA7C5 /* AddToDockTutorialView.swift */, 9F8E0F372CCFAA8A001EA7C5 /* AddToDockPromoView.swift */, 9F8E0F3C2CCFD071001EA7C5 /* AddToDockPromoViewModel.swift */, + 9F46BEF72CD8D7490092E0EF /* OnboardingView+AddToDockContent.swift */, ); path = AddToDock; sourceTree = ""; @@ -7408,6 +7411,7 @@ F4E1936625AF722F001D2666 /* HighlightCutOutView.swift in Sources */, 1E162605296840D80004127F /* Triangle.swift in Sources */, 6FDC64012C92F4A300DB71B3 /* NewTabPageIntroDataStoring.swift in Sources */, + 9F46BEF82CD8D7490092E0EF /* OnboardingView+AddToDockContent.swift in Sources */, B609D5522862EAFF0088CAC2 /* InlineWKDownloadDelegate.swift in Sources */, BDFF03222BA3D8E200F324C9 /* NetworkProtectionFeatureVisibility.swift in Sources */, B652DEFD287BE67400C12A9C /* UserScripts.swift in Sources */, diff --git a/DuckDuckGo/AppSettings.swift b/DuckDuckGo/AppSettings.swift index 2e212f2e59..af3f91b5a4 100644 --- a/DuckDuckGo/AppSettings.swift +++ b/DuckDuckGo/AppSettings.swift @@ -87,5 +87,5 @@ protocol AppSettings: AnyObject, AppDebugSettings { protocol AppDebugSettings { var onboardingHighlightsEnabled: Bool { get set } - var onboardingAddToDockEnabled: Bool { get set } + var onboardingAddToDockState: OnboardingAddToDockState { get set } } diff --git a/DuckDuckGo/AppUserDefaults.swift b/DuckDuckGo/AppUserDefaults.swift index 9d1ca894a3..4cd79c3e0b 100644 --- a/DuckDuckGo/AppUserDefaults.swift +++ b/DuckDuckGo/AppUserDefaults.swift @@ -84,6 +84,7 @@ public class AppUserDefaults: AppSettings { private struct DebugKeys { static let inspectableWebViewsEnabledKey = "com.duckduckgo.ios.debug.inspectableWebViewsEnabled" static let autofillDebugScriptEnabledKey = "com.duckduckgo.ios.debug.autofillDebugScriptEnabled" + static let onboardingAddToDockStateKey = "com.duckduckgo.ios.debug.onboardingAddToDockState" } private var userDefaults: UserDefaults? { @@ -422,8 +423,15 @@ public class AppUserDefaults: AppSettings { @UserDefaultsWrapper(key: .debugOnboardingHighlightsEnabledKey, defaultValue: false) var onboardingHighlightsEnabled: Bool - @UserDefaultsWrapper(key: .debugOnboardingAddToDockEnabledKey, defaultValue: false) - var onboardingAddToDockEnabled: Bool + var onboardingAddToDockState: OnboardingAddToDockState { + get { + guard let rawValue = userDefaults?.string(forKey: DebugKeys.onboardingAddToDockStateKey) else { return .disabled } + return OnboardingAddToDockState(rawValue: rawValue) ?? .disabled + } + set { + userDefaults?.set(newValue.rawValue, forKey: DebugKeys.onboardingAddToDockStateKey) + } + } } extension AppUserDefaults: AppConfigurationFetchStatistics { diff --git a/DuckDuckGo/DaxDialogs.swift b/DuckDuckGo/DaxDialogs.swift index d1793efb6b..d1d30b9e3a 100644 --- a/DuckDuckGo/DaxDialogs.swift +++ b/DuckDuckGo/DaxDialogs.swift @@ -41,6 +41,7 @@ protocol ContextualOnboardingLogic { var shouldShowPrivacyButtonPulse: Bool { get } var isShowingSearchSuggestions: Bool { get } var isShowingSitesSuggestions: Bool { get } + var isShowingAddToDockDialog: Bool { get } func setSearchMessageSeen() func setFireEducationMessageSeen() @@ -211,6 +212,7 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { private var settings: DaxDialogsSettings private var entityProviding: EntityProviding private let variantManager: VariantManager + private let addToDockManager: OnboardingAddToDockManaging private var nextHomeScreenMessageOverride: HomeScreenSpec? @@ -222,10 +224,13 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { /// Use singleton accessor, this is only accessible for tests init(settings: DaxDialogsSettings = DefaultDaxDialogsSettings(), entityProviding: EntityProviding, - variantManager: VariantManager = DefaultVariantManager()) { + variantManager: VariantManager = DefaultVariantManager(), + onboardingManager: OnboardingAddToDockManaging = OnboardingManager() + ) { self.settings = settings self.entityProviding = entityProviding self.variantManager = variantManager + self.addToDockManager = onboardingManager } private var isNewOnboarding: Bool { @@ -276,6 +281,11 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { return lastShownDaxDialogType.flatMap(BrowsingSpec.SpecType.init(rawValue:)) == .visitWebsite || currentHomeSpec == .subsequent } + var isShowingAddToDockDialog: Bool { + guard isNewOnboarding else { return false } + return currentHomeSpec == .final && addToDockManager.addToDockEnabledState == .contextual + } + var isEnabled: Bool { // skip dax dialogs in integration tests guard ProcessInfo.processInfo.environment["DAXDIALOGS"] != "false" else { return false } @@ -733,6 +743,7 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { private func clearOnboardingBrowsingData() { removeLastShownDaxDialog() removeLastVisitedOnboardingWebsite() + currentHomeSpec = nil } } diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index d1a2fd2e36..7800b7cc2a 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -2672,7 +2672,7 @@ extension MainViewController: AutoClearWorker { // Ideally this should happen once data clearing has finished AND the animation is finished if showNextDaxDialog { self.newTabPageViewController?.showNextDaxDialog() - } else if KeyboardSettings().onNewTab { + } else if KeyboardSettings().onNewTab && !self.contextualOnboardingLogic.isShowingAddToDockDialog { // If we're showing the Add to Dock dialog prevent address bar to become first responder. We want to make sure the user focues on the Add to Dock instructions. let showKeyboardAfterFireButton = DispatchWorkItem { self.enterSearch() } diff --git a/DuckDuckGo/NewTabPageViewController.swift b/DuckDuckGo/NewTabPageViewController.swift index 0f24599de6..8c02560816 100644 --- a/DuckDuckGo/NewTabPageViewController.swift +++ b/DuckDuckGo/NewTabPageViewController.swift @@ -310,9 +310,12 @@ extension NewTabPageViewController { guard let spec = dialogProvider.nextHomeScreenMessageNew() else { return } - let onDismiss = { + let onDismiss = { [weak self] in + guard let self else { return } dialogProvider.dismiss() self.dismissHostingController(didFinishNTPOnboarding: true) + // Make the address bar first responder after closing the new tab page final dialog. + self.launchNewSearch() } let daxDialogView = AnyView(factory.createDaxDialog(for: spec, onDismiss: onDismiss)) let hostingController = UIHostingController(rootView: daxDialogView) diff --git a/DuckDuckGo/OnboardingDebugView.swift b/DuckDuckGo/OnboardingDebugView.swift index 88976baa81..f6528aea25 100644 --- a/DuckDuckGo/OnboardingDebugView.swift +++ b/DuckDuckGo/OnboardingDebugView.swift @@ -22,6 +22,7 @@ import SwiftUI struct OnboardingDebugView: View { @StateObject private var viewModel = OnboardingDebugViewModel() + @State private var isShowingResetDaxDialogsAlert = false private let newOnboardingIntroStartAction: () -> Void @@ -45,8 +46,13 @@ struct OnboardingDebugView: View { } Section { - Toggle( - isOn: $viewModel.isOnboardingAddToDockLocalFlagEnabled, + Picker( + selection: $viewModel.onboardingAddToDockLocalFlagState, + content: { + ForEach(OnboardingAddToDockState.allCases) { state in + Text(verbatim: state.description).tag(state) + } + }, label: { Text(verbatim: "Onboarding Add to Dock local setting enabled") } @@ -57,6 +63,18 @@ struct OnboardingDebugView: View { Text(verbatim: "Requires internal user flag set to have an effect.") } + Section { + Button(action: { + viewModel.resetDaxDialogs() + isShowingResetDaxDialogsAlert = true + }, label: { + Text(verbatim: "Reset Dax Dialogs State") + }) + .alert(isPresented: $isShowingResetDaxDialogsAlert, content: { + Alert(title: Text(verbatim: "Dax Dialogs reset"), dismissButton: .cancel()) + }) + } + Section { Button(action: newOnboardingIntroStartAction, label: { let onboardingType = viewModel.isOnboardingHighlightsLocalFlagEnabled ? "Highlights" : "" @@ -74,22 +92,44 @@ final class OnboardingDebugViewModel: ObservableObject { } } - @Published var isOnboardingAddToDockLocalFlagEnabled: Bool { + @Published var onboardingAddToDockLocalFlagState: OnboardingAddToDockState { didSet { - manager.isAddToDockLocalFlagEnabled = isOnboardingAddToDockLocalFlagEnabled + manager.addToDockLocalFlagState = onboardingAddToDockLocalFlagState } } private let manager: OnboardingHighlightsDebugging & OnboardingAddToDockDebugging + private var settings: DaxDialogsSettings - init(manager: OnboardingHighlightsDebugging & OnboardingAddToDockDebugging = OnboardingManager()) { + init(manager: OnboardingHighlightsDebugging & OnboardingAddToDockDebugging = OnboardingManager(), settings: DaxDialogsSettings = DefaultDaxDialogsSettings()) { self.manager = manager + self.settings = settings isOnboardingHighlightsLocalFlagEnabled = manager.isOnboardingHighlightsLocalFlagEnabled - isOnboardingAddToDockLocalFlagEnabled = manager.isAddToDockLocalFlagEnabled + onboardingAddToDockLocalFlagState = manager.addToDockLocalFlagState } + func resetDaxDialogs() { + settings.isDismissed = false + settings.homeScreenMessagesSeen = 0 + settings.browsingAfterSearchShown = false + settings.browsingWithTrackersShown = false + settings.browsingWithoutTrackersShown = false + settings.browsingMajorTrackingSiteShown = false + settings.fireMessageExperimentShown = false + settings.fireButtonPulseDateShown = nil + settings.privacyButtonPulseShown = false + settings.browsingFinalDialogShown = false + settings.lastVisitedOnboardingWebsiteURLPath = nil + settings.lastShownContextualOnboardingDialogType = nil + } } #Preview { OnboardingDebugView(onNewOnboardingIntroStartAction: {}) } + +extension OnboardingAddToDockState: Identifiable { + var id: OnboardingAddToDockState { + self + } +} diff --git a/DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockTutorialView.swift b/DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockTutorialView.swift index e13ed55c9f..8b0a2a07bf 100644 --- a/DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockTutorialView.swift +++ b/DuckDuckGo/OnboardingExperiment/AddToDock/AddToDockTutorialView.swift @@ -32,6 +32,7 @@ struct AddToDockTutorialView: View { private let title: String private let message: String + private let cta: String private let action: () -> Void @State private var animateTitle = true @@ -44,10 +45,12 @@ struct AddToDockTutorialView: View { init( title: String, message: String, + cta: String, action: @escaping () -> Void ) { self.title = title self.message = message + self.cta = cta self.action = action } @@ -81,7 +84,7 @@ struct AddToDockTutorialView: View { } Button(action: action) { - Text(UserText.AddToDockOnboarding.Buttons.dismiss) + Text(cta) } .buttonStyle(PrimaryButtonStyle()) .visibility(showContent ? .visible : .invisible) @@ -110,6 +113,7 @@ struct AddToDockTutorial_Previews: PreviewProvider { AddToDockTutorialView( title: UserText.AddToDockOnboarding.Tutorial.title, message: UserText.AddToDockOnboarding.Tutorial.message, + cta: UserText.AddToDockOnboarding.Buttons.dismiss, action: {} ) .padding() diff --git a/DuckDuckGo/OnboardingExperiment/AddToDock/OnboardingView+AddToDockContent.swift b/DuckDuckGo/OnboardingExperiment/AddToDock/OnboardingView+AddToDockContent.swift new file mode 100644 index 0000000000..8baf1f18c1 --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/AddToDock/OnboardingView+AddToDockContent.swift @@ -0,0 +1,96 @@ +// +// OnboardingView+AddToDockContent.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 Onboarding + +extension OnboardingView { + + struct AddToDockPromoContentState { + var animateTitle = true + var animateMessage = false + var showContent = false + } + + struct AddToDockPromoContent: View { + + @State private var showAddToDockTutorial = false + + private var animateTitle: Binding + private var animateMessage: Binding + private var showContent: Binding + private let dismissAction: (_ fromAddToDock: Bool) -> Void + + init( + animateTitle: Binding = .constant(true), + animateMessage: Binding = .constant(true), + showContent: Binding = .constant(false), + dismissAction: @escaping (_ fromAddToDock: Bool) -> Void + ) { + self.animateTitle = animateTitle + self.animateMessage = animateMessage + self.showContent = showContent + self.dismissAction = dismissAction + } + + var body: some View { + if showAddToDockTutorial { + OnboardingAddToDockTutorialContent(cta: UserText.AddToDockOnboarding.Intro.tutorialDismissCTA) { + dismissAction(true) + } + } else { + ContextualDaxDialogContent( + title: UserText.AddToDockOnboarding.Intro.title, + titleFont: Font(UIFont.daxTitle3()), + message: NSAttributedString(string: UserText.AddToDockOnboarding.Intro.message), + messageFont: Font.system(size: 16), + customView: AnyView(addToDockPromoView), + customActionView: AnyView(customActionView) + ) + } + } + + private var addToDockPromoView: some View { + AddToDockPromoView() + .aspectRatio(contentMode: .fit) + .padding(.vertical) + } + + private var customActionView: some View { + VStack { + OnboardingCTAButton( + title: UserText.AddToDockOnboarding.Buttons.addToDockTutorial, + action: { + showAddToDockTutorial = true + } + ) + + OnboardingCTAButton( + title: UserText.AddToDockOnboarding.Intro.skipCTA, + buttonStyle: .ghost, + action: { + dismissAction(false) + } + ) + } + } + + } + +} diff --git a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift index f5bb5d47ec..eb3aee205b 100644 --- a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift +++ b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift @@ -185,10 +185,10 @@ struct OnboardingTrackersDoneDialog: View { struct OnboardingFinalDialog: View { let title = UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenTitle - let cta = UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenButton let logoPosition: DaxDialogLogoPosition let message: String + let cta: String let canShowAddToDockTutorial: Bool let dismissAction: (_ fromAddToDock: Bool) -> Void @@ -198,7 +198,7 @@ struct OnboardingFinalDialog: View { ScrollView(.vertical, showsIndicators: false) { DaxDialogView(logoPosition: logoPosition) { if showAddToDockTutorial { - OnboardingAddToDockTutorialContent { + OnboardingAddToDockTutorialContent(cta: UserText.AddToDockOnboarding.Buttons.dismiss) { dismissAction(true) } } else { @@ -206,6 +206,7 @@ struct OnboardingFinalDialog: View { title: title, titleFont: Font(UIFont.daxTitle3()), message: NSAttributedString(string: message), + messageFont: Font.system(size: 16), customView: AnyView(customView), customActionView: AnyView(customActionView) ) @@ -277,15 +278,17 @@ struct OnboardingCTAButton: View { struct OnboardingAddToDockTutorialContent: View { let title = UserText.AddToDockOnboarding.Tutorial.title let message = UserText.AddToDockOnboarding.Tutorial.message - let cta = UserText.AddToDockOnboarding.Buttons.dismiss + let cta: String let dismissAction: () -> Void var body: some View { AddToDockTutorialView( title: title, message: message, - action: dismissAction) + cta: cta, + action: dismissAction + ) } } @@ -322,6 +325,7 @@ struct OnboardingAddToDockTutorialContent: View { OnboardingFinalDialog( logoPosition: .top, message: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage, + cta: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenButton, canShowAddToDockTutorial: false, dismissAction: { _ in } ) @@ -332,6 +336,7 @@ struct OnboardingAddToDockTutorialContent: View { OnboardingFinalDialog( logoPosition: .left, message: UserText.AddToDockOnboarding.EndOfJourney.message, + cta: UserText.AddToDockOnboarding.Buttons.dismiss, canShowAddToDockTutorial: true, dismissAction: { _ in } ) @@ -353,11 +358,11 @@ struct OnboardingAddToDockTutorialContent: View { } #Preview("Add To Dock Tutorial - Light") { - OnboardingAddToDockTutorialContent(dismissAction: {}) + OnboardingAddToDockTutorialContent(cta: UserText.AddToDockOnboarding.Buttons.dismiss, dismissAction: {}) .preferredColorScheme(.light) } #Preview("Add To Dock Tutorial - Dark") { - OnboardingAddToDockTutorialContent(dismissAction: {}) + OnboardingAddToDockTutorialContent(cta: UserText.AddToDockOnboarding.Buttons.dismiss, dismissAction: {}) .preferredColorScheme(.dark) } diff --git a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/NewTabDaxDialogFactory.swift b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/NewTabDaxDialogFactory.swift index c4f74f8217..4d1a59eb66 100644 --- a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/NewTabDaxDialogFactory.swift +++ b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/NewTabDaxDialogFactory.swift @@ -99,14 +99,19 @@ final class NewTabDaxDialogFactory: NewTabDaxDialogProvider { } private func createFinalDialog(onDismiss: @escaping () -> Void) -> some View { - let message = if onboardingManager.isAddToDockEnabled { - UserText.AddToDockOnboarding.EndOfJourney.message + let shouldShowAddToDock = onboardingManager.addToDockEnabledState == .contextual + + let (message, cta) = if shouldShowAddToDock { + (UserText.AddToDockOnboarding.EndOfJourney.message, UserText.AddToDockOnboarding.Buttons.dismiss) } else { - onboardingManager.isOnboardingHighlightsEnabled ? UserText.HighlightsOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage : UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage + ( + onboardingManager.isOnboardingHighlightsEnabled ? UserText.HighlightsOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage : UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage, + UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenButton + ) } return FadeInView { - OnboardingFinalDialog(logoPosition: .top, message: message, canShowAddToDockTutorial: onboardingManager.isAddToDockEnabled) { [weak self] isDismissedFromAddToDock in + OnboardingFinalDialog(logoPosition: .top, message: message, cta: cta, canShowAddToDockTutorial: shouldShowAddToDock) { [weak self] isDismissedFromAddToDock in if isDismissedFromAddToDock { Logger.onboarding.debug("Dismissed from add to dock") } else { diff --git a/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift index b6b9f08289..5f0b58538c 100644 --- a/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift +++ b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift @@ -182,13 +182,18 @@ final class ExperimentContextualDaxDialogsFactory: ContextualDaxDialogsFactory { } private func endOfJourneyDialog(delegate: ContextualOnboardingDelegate, pixelName: Pixel.Event) -> some View { - let message = if onboardingManager.isAddToDockEnabled { - UserText.AddToDockOnboarding.EndOfJourney.message + let shouldShowAddToDock = onboardingManager.addToDockEnabledState == .contextual + + let (message, cta) = if shouldShowAddToDock { + (UserText.AddToDockOnboarding.EndOfJourney.message, UserText.AddToDockOnboarding.Buttons.dismiss) } else { - onboardingManager.isOnboardingHighlightsEnabled ? UserText.HighlightsOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage : UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage + ( + onboardingManager.isOnboardingHighlightsEnabled ? UserText.HighlightsOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage : UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage, + UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenButton + ) } - return OnboardingFinalDialog(logoPosition: .left, message: message, canShowAddToDockTutorial: onboardingManager.isAddToDockEnabled, dismissAction: { [weak delegate, weak self] isDismissedFromAddToDock in + return OnboardingFinalDialog(logoPosition: .left, message: message, cta: cta, canShowAddToDockTutorial: shouldShowAddToDock, dismissAction: { [weak delegate, weak self] isDismissedFromAddToDock in delegate?.didTapDismissContextualOnboardingAction() if isDismissedFromAddToDock { Logger.onboarding.debug("Dismissed from add to dock") diff --git a/DuckDuckGo/OnboardingExperiment/Manager/OnboardingManager.swift b/DuckDuckGo/OnboardingExperiment/Manager/OnboardingManager.swift index f3ab2408a4..b2f212410f 100644 --- a/DuckDuckGo/OnboardingExperiment/Manager/OnboardingManager.swift +++ b/DuckDuckGo/OnboardingExperiment/Manager/OnboardingManager.swift @@ -20,6 +20,23 @@ import BrowserServicesKit import Core +enum OnboardingAddToDockState: String, Equatable, CaseIterable, CustomStringConvertible { + case disabled + case intro + case contextual + + var description: String { + switch self { + case .disabled: + "Disabled" + case .intro: + "Onboarding Intro" + case .contextual: + "Dax Dialogs" + } + } +} + final class OnboardingManager { private var appDefaults: AppDebugSettings private let featureFlagger: FeatureFlagger @@ -75,27 +92,29 @@ extension OnboardingManager: OnboardingHighlightsManaging, OnboardingHighlightsD // MARK: - Add to Dock protocol OnboardingAddToDockManaging: AnyObject { - var isAddToDockEnabled: Bool { get } + var addToDockEnabledState: OnboardingAddToDockState { get } } protocol OnboardingAddToDockDebugging: AnyObject { - var isAddToDockLocalFlagEnabled: Bool { get set } + var addToDockLocalFlagState: OnboardingAddToDockState { get set } var isAddToDockFeatureFlagEnabled: Bool { get } } extension OnboardingManager: OnboardingAddToDockManaging, OnboardingAddToDockDebugging { - var isAddToDockEnabled: Bool { - // TODO: Add variant condition once the experiment is setup - isIphone && isAddToDockLocalFlagEnabled && isAddToDockFeatureFlagEnabled + var addToDockEnabledState: OnboardingAddToDockState { + // TODO: Add variant condition OR local conditions + guard isAddToDockFeatureFlagEnabled && isIphone else { return .disabled } + + return addToDockLocalFlagState } - var isAddToDockLocalFlagEnabled: Bool { + var addToDockLocalFlagState: OnboardingAddToDockState { get { - appDefaults.onboardingAddToDockEnabled + appDefaults.onboardingAddToDockState } set { - appDefaults.onboardingAddToDockEnabled = newValue + appDefaults.onboardingAddToDockState = newValue } } diff --git a/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingIntroViewModel.swift b/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingIntroViewModel.swift index af1622a3d0..349a6152ee 100644 --- a/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingIntroViewModel.swift +++ b/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingIntroViewModel.swift @@ -31,7 +31,7 @@ final class OnboardingIntroViewModel: ObservableObject { private var introSteps: [OnboardingIntroStep] private let pixelReporter: OnboardingIntroPixelReporting - private let onboardingManager: OnboardingHighlightsManaging + private let onboardingManager: OnboardingHighlightsManaging & OnboardingAddToDockManaging private let isIpad: Bool private let urlOpener: URLOpener private let appIconProvider: () -> AppIcon @@ -39,7 +39,7 @@ final class OnboardingIntroViewModel: ObservableObject { init( pixelReporter: OnboardingIntroPixelReporting, - onboardingManager: OnboardingHighlightsManaging = OnboardingManager(), + onboardingManager: OnboardingHighlightsManaging & OnboardingAddToDockManaging = OnboardingManager(), isIpad: Bool = UIDevice.current.userInterfaceIdiom == .pad, urlOpener: URLOpener = UIApplication.shared, appIconProvider: @escaping () -> AppIcon = { AppIconManager.shared.appIcon }, @@ -52,7 +52,9 @@ final class OnboardingIntroViewModel: ObservableObject { self.appIconProvider = appIconProvider self.addressBarPositionProvider = addressBarPositionProvider - introSteps = if onboardingManager.isOnboardingHighlightsEnabled { + introSteps = if onboardingManager.isOnboardingHighlightsEnabled && onboardingManager.addToDockEnabledState == .intro { + isIpad ? OnboardingIntroStep.highlightsIPadFlow : OnboardingIntroStep.highlightsAddToDockIphoneFlow + } else if onboardingManager.isOnboardingHighlightsEnabled { isIpad ? OnboardingIntroStep.highlightsIPadFlow : OnboardingIntroStep.highlightsIPhoneFlow } else { OnboardingIntroStep.defaultFlow @@ -85,6 +87,10 @@ final class OnboardingIntroViewModel: ObservableObject { handleSetDefaultBrowserAction() } + func addToDockContinueAction() { + state = makeViewState(for: .appIconSelection) + } + func appIconPickerContinueAction() { if appIconProvider() != .defaultAppIcon { pixelReporter.trackChooseCustomAppIconColor() @@ -130,6 +136,8 @@ private extension OnboardingIntroViewModel { OnboardingView.ViewState.onboarding(.init(type: .startOnboardingDialog, step: .hidden)) case .browserComparison: OnboardingView.ViewState.onboarding(.init(type: .browsersComparisonDialog, step: stepInfo())) + case .addToDockPromo: + OnboardingView.ViewState.onboarding(.init(type: .addToDockPromoDialog, step: stepInfo())) case .appIconSelection: OnboardingView.ViewState.onboarding(.init(type: .chooseAppIconDialog, step: stepInfo())) case .addressBarPositionSelection: @@ -140,7 +148,9 @@ private extension OnboardingIntroViewModel { } func handleSetDefaultBrowserAction() { - if onboardingManager.isOnboardingHighlightsEnabled { + if onboardingManager.addToDockEnabledState == .intro && onboardingManager.isOnboardingHighlightsEnabled { + state = makeViewState(for: .addToDockPromo) + } else if onboardingManager.isOnboardingHighlightsEnabled { state = makeViewState(for: .appIconSelection) pixelReporter.trackChooseAppIconImpression() } else { @@ -157,8 +167,10 @@ private enum OnboardingIntroStep { case browserComparison case appIconSelection case addressBarPositionSelection + case addToDockPromo static let defaultFlow: [OnboardingIntroStep] = [.introDialog, .browserComparison] static let highlightsIPhoneFlow: [OnboardingIntroStep] = [.introDialog, .browserComparison, .appIconSelection, .addressBarPositionSelection] static let highlightsIPadFlow: [OnboardingIntroStep] = [.introDialog, .browserComparison, .appIconSelection] + static let highlightsAddToDockIphoneFlow: [OnboardingIntroStep] = [.introDialog, .browserComparison, .addToDockPromo, .appIconSelection, .addressBarPositionSelection] } diff --git a/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingView.swift b/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingView.swift index cd834e55b2..6599129896 100644 --- a/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingView.swift +++ b/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingView.swift @@ -40,6 +40,7 @@ struct OnboardingView: View { @State private var appIconPickerContentState = AppIconPickerContentState() @State private var addressBarPositionContentState = AddressBarPositionContentState() + @State private var addToDockPromoContentState = AddToDockPromoContentState() init(model: OnboardingIntroViewModel) { self.model = model @@ -75,6 +76,10 @@ struct OnboardingView: View { case .browsersComparisonDialog: showComparisonButton = true animateComparisonText = false + case .addToDockPromoDialog: + addToDockPromoContentState.animateTitle = false + addToDockPromoContentState.animateMessage = false + addToDockPromoContentState.showContent = true case .chooseAppIconDialog: appIconPickerContentState.animateTitle = false appIconPickerContentState.animateMessage = false @@ -90,6 +95,8 @@ struct OnboardingView: View { introView case .browsersComparisonDialog: browsersComparisonView + case .addToDockPromoDialog: + addToDockPromoView case .chooseAppIconDialog: appIconPickerView case .chooseAddressBarPositionDialog: @@ -151,6 +158,11 @@ struct OnboardingView: View { .onboardingDaxDialogStyle() } + private var addToDockPromoView: some View { + AddToDockPromoContent(dismissAction: { _ in model.addToDockContinueAction() + }) + } + private var appIconPickerView: some View { AppIconPickerContent( animateTitle: $appIconPickerContentState.animateTitle, @@ -231,6 +243,7 @@ extension OnboardingView.ViewState.Intro { enum IntroType: Equatable { case startOnboardingDialog case browsersComparisonDialog + case addToDockPromoDialog case chooseAppIconDialog case chooseAddressBarPositionDialog } diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 15bef3837f..f742067903 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1428,5 +1428,12 @@ But if you *do* want a peek under the hood, you can find more information about static let title = NSLocalizedString("contextual.onboarding.addToDock.tutorial.title", value: "Adding me to your Dock is easy.", comment: "The title of the onboarding dialog popup that explains how to add the DDG browser icon to the dock.") static let message = NSLocalizedString("contextual.onboarding.addToDock.tutorial.message", value: "Find or search for the DuckDuckGo icon on your home screen. Then press and drag into place. That’s it!", comment: "The message of the onboarding dialog popup that explains how to add the DDG browser icon to the dock.") } + + public enum Intro { + static let title = NSLocalizedString("onboarding.addToDock.title", value: "Want to add me to your Dock?", comment: "The title of the onboarding dialog popup informing the user on the benefits of adding the DDG browser icon to the dock.") + static let message = NSLocalizedString("onboarding.addToDock.message", value: "I can paddle into the Dock and perch there until you need me.", comment: "The message of the onboarding dialog popup informing the user on the benefits of adding the DDG browser icon to the dock.") + static let skipCTA = NSLocalizedString("onboarding.addToDock.cta", value: "Skip", comment: "The title of the dialog button CTA to skip adding the DDB browser icon to the dock.") + static let tutorialDismissCTA = NSLocalizedString("onboarding.addToDock.tutorial.cta", value: "Got It", comment: "Button on the Add to Dock tutorial screen of the onboarding, it will dismiss the screen and proceed to the next step.") + } } } diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 2ff7e7ca21..f84a7a780f 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -1859,6 +1859,18 @@ https://duckduckgo.com/mac"; /* Text displayed on notification appearing in the address bar when the browser hides a cookie popup */ "omnibar.notification.popup-hidden" = "Pop-up Hidden"; +/* The title of the dialog button CTA to skip adding the DDB browser icon to the dock. */ +"onboarding.addToDock.cta" = "Skip"; + +/* The message of the onboarding dialog popup informing the user on the benefits of adding the DDG browser icon to the dock. */ +"onboarding.addToDock.message" = "I can paddle into the Dock and perch there until you need me."; + +/* The title of the onboarding dialog popup informing the user on the benefits of adding the DDG browser icon to the dock. */ +"onboarding.addToDock.title" = "Want to add me to your Dock?"; + +/* Button on the Add to Dock tutorial screen of the onboarding, it will dismiss the screen and proceed to the next step. */ +"onboarding.addToDock.tutorial.cta" = "Got It"; + /* Button to change the default browser */ "onboarding.browsers.cta" = "Choose Your Browser"; diff --git a/DuckDuckGoTests/AppSettingsMock.swift b/DuckDuckGoTests/AppSettingsMock.swift index a8c3db67bf..585d936cd9 100644 --- a/DuckDuckGoTests/AppSettingsMock.swift +++ b/DuckDuckGoTests/AppSettingsMock.swift @@ -95,6 +95,6 @@ class AppSettingsMock: AppSettings { var newTabPageIntroMessageSeenCount: Int = 0 var onboardingHighlightsEnabled: Bool = false - var onboardingAddToDockEnabled: Bool = false - + var onboardingAddToDockState: OnboardingAddToDockState = .disabled + } diff --git a/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift b/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift index 7f7665d7a6..a255eb4417 100644 --- a/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift +++ b/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift @@ -28,6 +28,7 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { private var delegate: ContextualOnboardingDelegateMock! private var settingsMock: ContextualOnboardingSettingsMock! private var pixelReporterMock: OnboardingPixelReporterMock! + private var onboardingManagerMock: OnboardingManagerMock! private var window: UIWindow! override func setUpWithError() throws { @@ -35,10 +36,12 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { delegate = ContextualOnboardingDelegateMock() settingsMock = ContextualOnboardingSettingsMock() pixelReporterMock = OnboardingPixelReporterMock() + onboardingManagerMock = OnboardingManagerMock() sut = ExperimentContextualDaxDialogsFactory( contextualOnboardingLogic: ContextualOnboardingLogicMock(), contextualOnboardingSettings: settingsMock, - contextualOnboardingPixelReporter: pixelReporterMock + contextualOnboardingPixelReporter: pixelReporterMock, + onboardingManager: onboardingManagerMock ) window = UIWindow(frame: UIScreen.main.bounds) window.makeKeyAndVisible() @@ -50,6 +53,7 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { delegate = nil settingsMock = nil pixelReporterMock = nil + onboardingManagerMock = nil sut = nil try super.tearDownWithError() } @@ -335,6 +339,36 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { // THEN XCTAssertTrue(pixelReporterMock.didCallTrackEndOfJourneyDialogDismiss) } + + // MARK: - Add To Dock + + func testWhenEndOfJourneyDialogAndAddToDockIsContextualThenReturnExpectedCopy() throws { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.final + onboardingManagerMock.addToDockEnabledState = .contextual + let dialog = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + + // WHEN + let result = try XCTUnwrap(find(OnboardingFinalDialog.self, in: dialog)) + + // THEN + XCTAssertEqual(result.message, UserText.AddToDockOnboarding.EndOfJourney.message) + XCTAssertEqual(result.cta, UserText.AddToDockOnboarding.Buttons.dismiss) + } + + func testWhenEndOfJourneyDialogAndAddToDockIsContextualThenCanShowAddToDockTutorialIsTrue() throws { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.final + onboardingManagerMock.addToDockEnabledState = .contextual + let dialog = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + let view = try XCTUnwrap(find(OnboardingFinalDialog.self, in: dialog)) + + // WHEN + let result = view.canShowAddToDockTutorial + + // THEN + XCTAssertTrue(result) + } } extension ContextualDaxDialogsFactoryTests { diff --git a/DuckDuckGoTests/ContextualOnboardingNewTabDialogFactoryTests.swift b/DuckDuckGoTests/ContextualOnboardingNewTabDialogFactoryTests.swift index 8d54d53d5a..aca4f086d2 100644 --- a/DuckDuckGoTests/ContextualOnboardingNewTabDialogFactoryTests.swift +++ b/DuckDuckGoTests/ContextualOnboardingNewTabDialogFactoryTests.swift @@ -29,6 +29,7 @@ class ContextualOnboardingNewTabDialogFactoryTests: XCTestCase { var mockDelegate: CapturingOnboardingNavigationDelegate! var contextualOnboardingLogicMock: ContextualOnboardingLogicMock! var pixelReporterMock: OnboardingPixelReporterMock! + var onboardingManagerMock: OnboardingManagerMock! var onDismissCalled: Bool! var window: UIWindow! @@ -36,9 +37,15 @@ class ContextualOnboardingNewTabDialogFactoryTests: XCTestCase { super.setUp() mockDelegate = CapturingOnboardingNavigationDelegate() contextualOnboardingLogicMock = ContextualOnboardingLogicMock() + onboardingManagerMock = OnboardingManagerMock() onDismissCalled = false pixelReporterMock = OnboardingPixelReporterMock() - factory = NewTabDaxDialogFactory(delegate: mockDelegate, contextualOnboardingLogic: contextualOnboardingLogicMock, onboardingPixelReporter: pixelReporterMock) + factory = NewTabDaxDialogFactory( + delegate: mockDelegate, + contextualOnboardingLogic: contextualOnboardingLogicMock, + onboardingPixelReporter: pixelReporterMock, + onboardingManager: onboardingManagerMock + ) window = UIWindow(frame: UIScreen.main.bounds) window.makeKeyAndVisible() } @@ -51,6 +58,7 @@ class ContextualOnboardingNewTabDialogFactoryTests: XCTestCase { onDismissCalled = nil contextualOnboardingLogicMock = nil pixelReporterMock = nil + onboardingManagerMock = nil super.tearDown() } @@ -163,6 +171,36 @@ class ContextualOnboardingNewTabDialogFactoryTests: XCTestCase { XCTAssertTrue(pixelReporterMock.didCallTrackEndOfJourneyDialogDismiss) } + // MARK: - Add To Dock + + func testWhenEndOfJourneyDialogAndAddToDockIsContextualThenReturnExpectedCopy() throws { + // GIVEN + let spec = DaxDialogs.HomeScreenSpec.final + onboardingManagerMock.addToDockEnabledState = .contextual + let dialog = factory.createDaxDialog(for: spec, onDismiss: {}) + + // WHEN + let result = try XCTUnwrap(find(OnboardingFinalDialog.self, in: dialog)) + + // THEN + XCTAssertEqual(result.message, UserText.AddToDockOnboarding.EndOfJourney.message) + XCTAssertEqual(result.cta, UserText.AddToDockOnboarding.Buttons.dismiss) + } + + func testWhenEndOfJourneyDialogAndAddToDockIsContextualThenCanShowAddToDockTutorialIsTrue() throws { + // GIVEN + let spec = DaxDialogs.HomeScreenSpec.final + onboardingManagerMock.addToDockEnabledState = .contextual + let dialog = factory.createDaxDialog(for: spec, onDismiss: {}) + let view = try XCTUnwrap(find(OnboardingFinalDialog.self, in: dialog)) + + // WHEN + let result = view.canShowAddToDockTutorial + + // THEN + XCTAssertTrue(result) + } + } private extension ContextualOnboardingNewTabDialogFactoryTests { diff --git a/DuckDuckGoTests/DaxDialogTests.swift b/DuckDuckGoTests/DaxDialogTests.swift index 22bcfc1c7d..7a512f5cae 100644 --- a/DuckDuckGoTests/DaxDialogTests.swift +++ b/DuckDuckGoTests/DaxDialogTests.swift @@ -1100,6 +1100,52 @@ final class DaxDialog: XCTestCase { XCTAssertTrue(result) } + // MARK: - States + + func testWhenIsShowingAddToDockDialogCalledAndHomeSpecIsFinalAndAddToDockIsEnabledThenReturnTrue() { + // GIVEN + let onboardingManagerMock = OnboardingManagerMock() + onboardingManagerMock.addToDockEnabledState = .contextual + settings.fireMessageExperimentShown = true + let sut = makeExperimentSUT(settings: settings, onboardingManager: onboardingManagerMock) + _ = sut.nextHomeScreenMessageNew() + + // WHEN + let result = sut.isShowingAddToDockDialog + + // THEN + XCTAssertTrue(result) + } + + func testWhenIsShowingAddToDockDialogCalledAndHomeSpecIsNotFinalThenReturnFalse() { + // GIVEN + let onboardingManagerMock = OnboardingManagerMock() + onboardingManagerMock.addToDockEnabledState = .contextual + let sut = makeExperimentSUT(settings: settings, onboardingManager: onboardingManagerMock) + _ = sut.nextHomeScreenMessageNew() + + // WHEN + let result = sut.isShowingAddToDockDialog + + // THEN + XCTAssertFalse(result) + } + + func testWhenIsShowingAddToDockDialogCalledAndHomeSpeciIsFinalAndAddToDockIsNotEnabledReturnFalse() { + // GIVEN + let onboardingManagerMock = OnboardingManagerMock() + onboardingManagerMock.addToDockEnabledState = .disabled + settings.fireMessageExperimentShown = true + let sut = makeExperimentSUT(settings: settings, onboardingManager: onboardingManagerMock) + _ = sut.nextHomeScreenMessageNew() + + // WHEN + let result = sut.isShowingAddToDockDialog + + // THEN + XCTAssertFalse(result) + } + private func detectedTrackerFrom(_ url: URL, pageUrl: String) -> DetectedRequest { let entity = entityProvider.entity(forHost: url.host!) return DetectedRequest(url: url.absoluteString, @@ -1123,11 +1169,11 @@ final class DaxDialog: XCTestCase { protectionStatus: protectionStatus) } - private func makeExperimentSUT(settings: DaxDialogsSettings) -> DaxDialogs { + private func makeExperimentSUT(settings: DaxDialogsSettings, onboardingManager: OnboardingAddToDockManaging = OnboardingManagerMock()) -> DaxDialogs { var mockVariantManager = MockVariantManager() mockVariantManager.isSupportedBlock = { feature in feature == .contextualDaxDialogs } - return DaxDialogs(settings: settings, entityProviding: entityProvider, variantManager: mockVariantManager) + return DaxDialogs(settings: settings, entityProviding: entityProvider, variantManager: mockVariantManager, onboardingManager: onboardingManager) } } diff --git a/DuckDuckGoTests/OnboardingIntroViewModelTests.swift b/DuckDuckGoTests/OnboardingIntroViewModelTests.swift index 2c8ee42d50..2637b72263 100644 --- a/DuckDuckGoTests/OnboardingIntroViewModelTests.swift +++ b/DuckDuckGoTests/OnboardingIntroViewModelTests.swift @@ -475,4 +475,34 @@ final class OnboardingIntroViewModelTests: XCTestCase { XCTAssertEqual(result, UserText.HighlightsOnboardingExperiment.BrowsersComparison.title) } + // MARK: - Add To Dock + + func testWhenSetDefaultBrowserActionIsCalledAndIsHighlightsIphoneFlowThenViewStateChangesToAddToDockPromoDialogAndProgressIs2Of4() { + // GIVEN + onboardingManager.isOnboardingHighlightsEnabled = true + onboardingManager.addToDockEnabledState = .intro + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingPixelReporterMock(), onboardingManager: onboardingManager, isIpad: false, urlOpener: MockURLOpener()) + XCTAssertEqual(sut.state, .landing) + + // WHEN + sut.setDefaultBrowserAction() + + // THEN + XCTAssertEqual(sut.state, .onboarding(.init(type: .addToDockPromoDialog, step: .init(currentStep: 2, totalSteps: 4)))) + } + + func testWhenAddtoDockContinueActionIsCalledAndIsHighlightsIphoneFlowThenThenViewStateChangesToChooseAppIconAndProgressIs3of4() { + // GIVEN + onboardingManager.isOnboardingHighlightsEnabled = true + onboardingManager.addToDockEnabledState = .intro + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingPixelReporterMock(), onboardingManager: onboardingManager, isIpad: false) + XCTAssertEqual(sut.state, .landing) + + // WHEN + sut.addToDockContinueAction() + + // THEN + XCTAssertEqual(sut.state, .onboarding(.init(type: .chooseAppIconDialog, step: .init(currentStep: 3, totalSteps: 4)))) + } + } diff --git a/DuckDuckGoTests/OnboardingManagerMock.swift b/DuckDuckGoTests/OnboardingManagerMock.swift index 9322299bfe..dcd8473b60 100644 --- a/DuckDuckGoTests/OnboardingManagerMock.swift +++ b/DuckDuckGoTests/OnboardingManagerMock.swift @@ -20,6 +20,7 @@ import Foundation @testable import DuckDuckGo -final class OnboardingManagerMock: OnboardingHighlightsManaging { +final class OnboardingManagerMock: OnboardingHighlightsManaging, OnboardingAddToDockManaging { var isOnboardingHighlightsEnabled: Bool = false + var addToDockEnabledState: OnboardingAddToDockState = .disabled } diff --git a/DuckDuckGoTests/OnboardingManagerTests.swift b/DuckDuckGoTests/OnboardingManagerTests.swift index c5f0c2bff8..ef7d174fc0 100644 --- a/DuckDuckGoTests/OnboardingManagerTests.swift +++ b/DuckDuckGoTests/OnboardingManagerTests.swift @@ -155,26 +155,37 @@ final class OnboardingManagerTests: XCTestCase { // MARK: - Add to Dock - func testWhenIsAddToDockLocalFlagEnabledCalledAndAppDefaultsOnboardingAddToDockEnabledIsTrueThenReturnTrue() { + func testWhenAddToDockLocalFlagStateCalledAndAppDefaultsOnboardingAddToDockStateIsIntroThenReturnIntro() { // GIVEN - appSettingsMock.onboardingAddToDockEnabled = true + appSettingsMock.onboardingAddToDockState = .intro // WHEN - let result = sut.isAddToDockLocalFlagEnabled + let result = sut.addToDockLocalFlagState // THEN - XCTAssertTrue(result) + XCTAssertEqual(result, .intro) } - func testWhenIsAddToDockLocalFlagEnabledCalledAndAppDefaultsOnboardingAddToDockEnabledIsFalseThenReturnFalse() { + func testWhenAddToDockLocalFlagStateCalledAndAppDefaultsOnboardingAddToDockStateIsContextualThenReturnContextual() { // GIVEN - appSettingsMock.onboardingAddToDockEnabled = false + appSettingsMock.onboardingAddToDockState = .contextual // WHEN - let result = sut.isAddToDockLocalFlagEnabled + let result = sut.addToDockLocalFlagState // THEN - XCTAssertFalse(result) + XCTAssertEqual(result, .contextual) + } + + func testWhenAddToDockLocalFlagStateCalledAndAppDefaultsOnboardingAddToDockStateIsDisabledThenReturnDisabled() { + // GIVEN + appSettingsMock.onboardingAddToDockState = .disabled + + // WHEN + let result = sut.addToDockLocalFlagState + + // THEN + XCTAssertEqual(result, .disabled) } func testWhenIsAddToDockFeatureFlagEnabledCalledAndFeaturFlaggerFeatureIsOnThenReturnTrue() { @@ -199,65 +210,102 @@ final class OnboardingManagerTests: XCTestCase { XCTAssertFalse(result) } - func testWhenIsAddToDockEnabledCalledAndLocalFlagEnabledIsFalseAndFeatureFlagIsFalseThenReturnFalse() { + func testWhenAddToDockStateCalledAndLocalFlagStateIsDisabledAndFeatureFlagIsFalseThenReturnDisabled() { // GIVEN - appSettingsMock.onboardingAddToDockEnabled = false + appSettingsMock.onboardingAddToDockState = .disabled featureFlaggerMock.enabledFeatureFlags = [] // WHEN - let result = sut.isAddToDockEnabled + let result = sut.addToDockEnabledState // THEN - XCTAssertFalse(result) + XCTAssertEqual(result, .disabled) } - func testWhenIsAddToDockEnabledCalledAndLocalFlagEnabledIsTrueAndFeatureFlagIsFalseThenReturnFalse() { + func testWhenAddToDockStateCalledAndLocalFlagStateIsIntroAndFeatureFlagIsFalseThenReturnDisabled() { // GIVEN - appSettingsMock.onboardingAddToDockEnabled = true + appSettingsMock.onboardingAddToDockState = .intro featureFlaggerMock.enabledFeatureFlags = [] // WHEN - let result = sut.isAddToDockEnabled + let result = sut.addToDockEnabledState // THEN - XCTAssertFalse(result) + XCTAssertEqual(result, .disabled) + } + + func testWhenAddToDockStateCalledAndLocalFlagStateIsContextualAndFeatureFlagIsFalseThenReturnDisabled() { + // GIVEN + appSettingsMock.onboardingAddToDockState = .contextual + featureFlaggerMock.enabledFeatureFlags = [] + + // WHEN + let result = sut.addToDockEnabledState + + // THEN + XCTAssertEqual(result, .disabled) } - func testWhenIsAddToDockEnabledCalledAndLocalFlagEnabledIsFalseAndFeatureFlagEnabledIsTrueThenReturnFalse() { + func testWhenAddToDockStateCalledAndLocalFlagStateIsDisabledAndFeatureFlagEnabledIsTrueThenReturnDisabled() { // GIVEN - appSettingsMock.onboardingAddToDockEnabled = false + appSettingsMock.onboardingAddToDockState = .disabled featureFlaggerMock.enabledFeatureFlags = [.onboardingAddToDock] // WHEN - let result = sut.isAddToDockEnabled + let result = sut.addToDockEnabledState // THEN - XCTAssertFalse(result) + XCTAssertEqual(result, .disabled) } - func testWhenIsAddToDockEnabledAndLocalFlagEnabledIsTrueAndFeatureFlagEnabledIsTrueThenReturnTrue() { + func testWhenAddToDockStateAndLocalFlagStateIsIntroAndFeatureFlagEnabledIsTrueThenReturnIntro() { // GIVEN - appSettingsMock.onboardingAddToDockEnabled = true + appSettingsMock.onboardingAddToDockState = .intro featureFlaggerMock.enabledFeatureFlags = [.onboardingAddToDock] // WHEN - let result = sut.isAddToDockEnabled + let result = sut.addToDockEnabledState // THEN - XCTAssertTrue(result) + XCTAssertEqual(result, .intro) + } + + func testWhenAddToDockStateAndLocalFlagStateIsContextualAndFeatureFlagEnabledIsTrueThenReturnContextual() { + // GIVEN + appSettingsMock.onboardingAddToDockState = .contextual + featureFlaggerMock.enabledFeatureFlags = [.onboardingAddToDock] + + // WHEN + let result = sut.addToDockEnabledState + + // THEN + XCTAssertEqual(result, .contextual) } - func testWhenIsAddToDockEnabledAndLocalAndFeatureFlagsAreEnabledAndDeviceIsIpadReturnFalse() { + func testWhenAddToDockStateAndLocalFlagStateIsIntroAndFeatureFlagsIsEnabledAndDeviceIsIpadReturnDisabled() { // GIVEN - appSettingsMock.onboardingAddToDockEnabled = true + appSettingsMock.onboardingAddToDockState = .intro featureFlaggerMock.enabledFeatureFlags = [.onboardingAddToDock] sut = OnboardingManager(appDefaults: appSettingsMock, featureFlagger: featureFlaggerMock, variantManager: variantManagerMock, isIphone: false) // WHEN - let result = sut.isAddToDockEnabled + let result = sut.addToDockEnabledState // THEN - XCTAssertFalse(result) + XCTAssertEqual(result, .disabled) + } + + func testWhenAddToDockStateAndLocalFlagStateIsContextualAndFeatureFlagsIsEnabledAndDeviceIsIpadReturnDisabled() { + // GIVEN + appSettingsMock.onboardingAddToDockState = .contextual + featureFlaggerMock.enabledFeatureFlags = [.onboardingAddToDock] + sut = OnboardingManager(appDefaults: appSettingsMock, featureFlagger: featureFlaggerMock, variantManager: variantManagerMock, isIphone: false) + + // WHEN + let result = sut.addToDockEnabledState + + // THEN + XCTAssertEqual(result, .disabled) } } diff --git a/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift b/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift index 5bafc76247..1e22244892 100644 --- a/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift +++ b/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift @@ -242,6 +242,7 @@ final class ContextualOnboardingLogicMock: ContextualOnboardingLogic { var shouldShowPrivacyButtonPulse: Bool = false var isShowingSearchSuggestions: Bool = false var isShowingSitesSuggestions: Bool = false + var isShowingAddToDockDialog: Bool = false func setFireEducationMessageSeen() { didCallSetFireEducationMessageSeen = true From d9a02a30214a831491d60199d1e099dd5b95904f Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 5 Nov 2024 18:45:09 +0100 Subject: [PATCH 29/29] Send pixel on sync secure storage failure (#3542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/414235014887631/1208700858621924/f **Description**: This was reviewed here: https://github.com/duckduckgo/iOS/pull/3530 and merged already to the release branch, but there's been conflicts since. So it needs an extra PR to resolve them. **Steps to test this PR**: 1. Just make sure this compiles and is pointing to latest BSK release. **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) --------- Co-authored-by: Daniel Bernal --- Core/PixelEvent.swift | 2 ++ Core/SyncErrorHandler.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index ed52ce6a71..869d390f13 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -624,6 +624,7 @@ extension Pixel { case syncRemoveDeviceError case syncDeleteAccountError case syncLoginExistingAccountError + case syncSecureStorageReadError case syncGetOtherDevices case syncGetOtherDevicesCopy @@ -1437,6 +1438,7 @@ extension Pixel.Event { case .syncRemoveDeviceError: return "m_d_sync_remove_device_error" case .syncDeleteAccountError: return "m_d_sync_delete_account_error" case .syncLoginExistingAccountError: return "m_d_sync_login_existing_account_error" + case .syncSecureStorageReadError: return "m_d_sync_secure_storage_error" case .syncGetOtherDevices: return "sync_get_other_devices" case .syncGetOtherDevicesCopy: return "sync_get_other_devices_copy" diff --git a/Core/SyncErrorHandler.swift b/Core/SyncErrorHandler.swift index a3ff07e794..93609732ba 100644 --- a/Core/SyncErrorHandler.swift +++ b/Core/SyncErrorHandler.swift @@ -100,6 +100,8 @@ public class SyncErrorHandler: EventMapping { Pixel.fire(pixel: .syncFailedToLoadAccount, error: error) case .failedToSetupEngine: Pixel.fire(pixel: .syncFailedToSetupEngine, error: error) + case .failedToReadSecureStore: + Pixel.fire(pixel: .syncSecureStorageReadError, error: error) default: // Should this be so generic? let domainEvent = Pixel.Event.syncSentUnauthenticatedRequest