diff --git a/Configuration/Version.xcconfig b/Configuration/Version.xcconfig index bd92fe0db4..657865d520 100644 --- a/Configuration/Version.xcconfig +++ b/Configuration/Version.xcconfig @@ -1 +1 @@ -MARKETING_VERSION = 7.126.0 +MARKETING_VERSION = 7.127.0 diff --git a/Core/AppPrivacyConfigurationDataProvider.swift b/Core/AppPrivacyConfigurationDataProvider.swift index 4961a858f8..5f5446df62 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 = "\"6e864c9ba3042d9fd90f2e4f9713ef06\"" - public static let embeddedDataSHA = "59fc3aa71802c502fbe5042d03da8c1007f4f68014405386fc4c2fa267729319" + public static let embeddedDataETag = "\"8d80c53fc5696854e2ef43bcb28089a1\"" + public static let embeddedDataSHA = "cdc42bdffa7ab5478ca80febf325cf689148cd19a68153e0a2cafed888b1d1ce" } public var embeddedDataEtag: String { diff --git a/Core/BookmarksStateRepair.swift b/Core/BookmarksStateRepair.swift new file mode 100644 index 0000000000..0c5e09e96e --- /dev/null +++ b/Core/BookmarksStateRepair.swift @@ -0,0 +1,83 @@ +// +// BookmarksStateRepair.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 CoreData +import Bookmarks +import Persistence + +public class BookmarksStateRepair { + + enum Constants { + static let pendingDeletionRepaired = "stateRepair_pendingDeletionRepaired" + } + + public enum RepairStatus: Equatable { + case alreadyPerformed + case noBrokenData + case dataRepaired + case repairError(Error) + + public static func == (lhs: BookmarksStateRepair.RepairStatus, rhs: BookmarksStateRepair.RepairStatus) -> Bool { + switch (lhs, rhs) { + case (.alreadyPerformed, .alreadyPerformed), (.noBrokenData, .noBrokenData), (.dataRepaired, .dataRepaired), (.repairError, .repairError): + return true + default: + return false + } + } + } + + let keyValueStore: KeyValueStoring + + public init(keyValueStore: KeyValueStoring) { + self.keyValueStore = keyValueStore + } + + public func validateAndRepairPendingDeletionState(in context: NSManagedObjectContext) -> RepairStatus { + + guard keyValueStore.object(forKey: Constants.pendingDeletionRepaired) == nil else { + return .alreadyPerformed + } + + do { + let fr = BookmarkEntity.fetchRequest() + fr.predicate = NSPredicate(format: "%K == nil", #keyPath(BookmarkEntity.isPendingDeletion)) + + let result = try context.fetch(fr) + + if !result.isEmpty { + for obj in result { + obj.setValue(false, forKey: #keyPath(BookmarkEntity.isPendingDeletion)) + } + + try context.save() + + keyValueStore.set(true, forKey: Constants.pendingDeletionRepaired) + return .dataRepaired + } else { + keyValueStore.set(true, forKey: Constants.pendingDeletionRepaired) + return .noBrokenData + } + } catch { + return .repairError(error) + } + } + +} diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 450d4f39bd..d8a87f3132 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -313,6 +313,7 @@ extension Pixel { case networkProtectionControllerStartFailure case networkProtectionTunnelStartAttempt + case networkProtectionTunnelStartAttemptOnDemandWithoutAccessToken case networkProtectionTunnelStartSuccess case networkProtectionTunnelStartFailure @@ -338,7 +339,7 @@ extension Pixel { case networkProtectionTunnelFailureDetected case networkProtectionTunnelFailureRecovered - + case networkProtectionLatency(quality: NetworkProtectionLatencyMonitor.ConnectionQuality) case networkProtectionLatencyError @@ -415,6 +416,9 @@ extension Pixel { case networkProtectionDNSUpdateCustom case networkProtectionDNSUpdateDefault + case networkProtectionVPNConfigurationRemoved + case networkProtectionVPNConfigurationRemovalFailed + // MARK: remote messaging pixels case remoteMessageShown @@ -504,7 +508,10 @@ extension Pixel { case debugBookmarksStructureLost case debugBookmarksInvalidRoots case debugBookmarksValidationFailed - + + case debugBookmarksPendingDeletionFixed + case debugBookmarksPendingDeletionRepairError + case debugCannotClearObservationsDatabase case debugWebsiteDataStoresNotClearedMultiple case debugWebsiteDataStoresNotClearedOne @@ -643,7 +650,6 @@ extension Pixel { case privacyProRestoreAfterPurchaseAttempt case privacyProSubscriptionActivated case privacyProWelcomeAddDevice - case privacyProSettingsAddDevice case privacyProAddDeviceEnterEmail case privacyProWelcomeVPN case privacyProWelcomePersonalInformationRemoval @@ -1020,6 +1026,7 @@ extension Pixel.Event { case .networkProtectionControllerStartSuccess: return "m_netp_controller_start_success" case .networkProtectionControllerStartFailure: return "m_netp_controller_start_failure" case .networkProtectionTunnelStartAttempt: return "m_netp_tunnel_start_attempt" + case .networkProtectionTunnelStartAttemptOnDemandWithoutAccessToken: return "m_netp_tunnel_start_attempt_on_demand_without_access_token" case .networkProtectionTunnelStartSuccess: return "m_netp_tunnel_start_success" case .networkProtectionTunnelStartFailure: return "m_netp_tunnel_start_failure" case .networkProtectionTunnelStopAttempt: return "m_netp_tunnel_stop_attempt" @@ -1098,6 +1105,9 @@ extension Pixel.Event { case .networkProtectionDNSUpdateCustom: return "m_netp_ev_update_dns_custom" case .networkProtectionDNSUpdateDefault: return "m_netp_ev_update_dns_default" + case .networkProtectionVPNConfigurationRemoved: return "m_netp_vpn_configuration_removed" + case .networkProtectionVPNConfigurationRemovalFailed: return "m_netp_vpn_configuration_removal_failed" + // MARK: remote messaging pixels case .remoteMessageShown: return "m_remote_message_shown" @@ -1183,6 +1193,9 @@ extension Pixel.Event { case .debugBookmarksInvalidRoots: return "m_d_bookmarks_invalid_roots" case .debugBookmarksValidationFailed: return "m_d_bookmarks_validation_failed" + case .debugBookmarksPendingDeletionFixed: return "m_debug_bookmarks_pending_deletion_fixed" + case .debugBookmarksPendingDeletionRepairError: return "m_debug_bookmarks_pending_deletion_repair_error" + case .debugCannotClearObservationsDatabase: return "m_d_cannot_clear_observations_database" case .debugWebsiteDataStoresNotClearedMultiple: return "m_d_wkwebsitedatastoresnotcleared_multiple" case .debugWebsiteDataStoresNotClearedOne: return "m_d_wkwebsitedatastoresnotcleared_one" @@ -1340,7 +1353,6 @@ extension Pixel.Event { case .privacyProRestoreAfterPurchaseAttempt: return "m_privacy-pro_app_subscription-restore-after-purchase-attempt_success" case .privacyProSubscriptionActivated: return "m_privacy-pro_app_subscription_activated_u" case .privacyProWelcomeAddDevice: return "m_privacy-pro_welcome_add-device_click_u" - case .privacyProSettingsAddDevice: return "m_privacy-pro_settings_add-device_click" case .privacyProAddDeviceEnterEmail: return "m_privacy-pro_add-device_enter-email_click" case .privacyProWelcomeVPN: return "m_privacy-pro_welcome_vpn_click_u" case .privacyProWelcomePersonalInformationRemoval: return "m_privacy-pro_welcome_personal-information-removal_click_u" diff --git a/Core/ios-config.json b/Core/ios-config.json index 95bd0f251d..5d30590562 100644 --- a/Core/ios-config.json +++ b/Core/ios-config.json @@ -1,6 +1,6 @@ { "readme": "https://github.com/duckduckgo/privacy-configuration", - "version": 1719225510541, + "version": 1719853254811, "features": { "adClickAttribution": { "readme": "https://help.duckduckgo.com/duckduckgo-help-pages/privacy/web-tracking-protections/#3rd-party-tracker-loading-protection", @@ -300,6 +300,9 @@ { "domain": "malmostadsteater.se" }, + { + "domain": "hertz.co.uk" + }, { "domain": "hertz.com" }, @@ -353,7 +356,7 @@ } } }, - "hash": "63f99d701b33267410e99d637e3f7778" + "hash": "c47027bc9d235b909d868f5ffa97d3c9" }, "autofill": { "exceptions": [ @@ -1010,6 +1013,72 @@ { "domain": "pocketbook.digital" }, + { + "domain": "vinted.at" + }, + { + "domain": "vinted.be" + }, + { + "domain": "vinted.com" + }, + { + "domain": "vinted.co.uk" + }, + { + "domain": "vinted.cz" + }, + { + "domain": "vinted.de" + }, + { + "domain": "vinted.dk" + }, + { + "domain": "vinted.es" + }, + { + "domain": "vinted.fi" + }, + { + "domain": "vinted.fr" + }, + { + "domain": "vinted.gr" + }, + { + "domain": "vinted.hr" + }, + { + "domain": "vinted.hu" + }, + { + "domain": "vinted.it" + }, + { + "domain": "vinted.lt" + }, + { + "domain": "vinted.lu" + }, + { + "domain": "vinted.nl" + }, + { + "domain": "vinted.pl" + }, + { + "domain": "vinted.pt" + }, + { + "domain": "vinted.ro" + }, + { + "domain": "vinted.se" + }, + { + "domain": "vinted.sk" + }, { "domain": "marvel.com" }, @@ -1035,7 +1104,7 @@ } }, "state": "disabled", - "hash": "4104bd45b436c084ff8fd0297883d40e" + "hash": "1d9a8170b92ba9902d28fad82713a403" }, "clickToPlay": { "exceptions": [ @@ -1290,11 +1359,6 @@ "state": "disabled", "hash": "314df3f87ca501a86eb1d04db8bf7bbc" }, - "dummyWebMessageListener": { - "exceptions": [], - "state": "disabled", - "hash": "728493ef7a1488e4781656d3f9db84aa" - }, "elementHiding": { "exceptions": [ { @@ -2455,6 +2519,15 @@ } ] }, + { + "domain": "eporner.com", + "rules": [ + { + "selector": "#admobiletop", + "type": "hide" + } + ] + }, { "domain": "essentiallysports.com", "rules": [ @@ -4486,7 +4559,7 @@ ] }, "state": "enabled", - "hash": "85bebc8a4c01285c2cfb97032a0610bb" + "hash": "573f3bc0a0f15e5dc7d561034e7a082d" }, "exceptionHandler": { "exceptions": [ @@ -5239,6 +5312,11 @@ "state": "disabled", "hash": "3c850040d1c9b3e06841b1491c5d2940" }, + "remoteMessaging": { + "state": "enabled", + "exceptions": [], + "hash": "697382e31649d84b01166f1dc6f790d6" + }, "requestFilterer": { "state": "disabled", "exceptions": [ @@ -5313,6 +5391,18 @@ "minSupportedVersion": "7.104.0", "hash": "d7dca6ee484eadebb5133e3f15fd9f41" }, + "toggleReports": { + "state": "enabled", + "exceptions": [], + "settings": { + "dismissLogicEnabled": true, + "dismissInterval": 172800, + "promptLimitLogicEnabled": true, + "promptInterval": 172800, + "maxPromptCount": 3 + }, + "hash": "a05b4413a3745762c46ec89aa5037693" + }, "trackerAllowlist": { "state": "enabled", "settings": { @@ -8746,6 +8836,16 @@ }, "hash": "25d935f0276cd0d81fc6f25811f7cb36" }, + "webViewBlobDownload": { + "exceptions": [], + "state": "disabled", + "hash": "728493ef7a1488e4781656d3f9db84aa" + }, + "windowsExternalPreviewReleases": { + "exceptions": [], + "state": "disabled", + "hash": "728493ef7a1488e4781656d3f9db84aa" + }, "windowsPermissionUsage": { "exceptions": [], "state": "disabled", diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 4cac42c404..530e6d59b6 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -172,6 +172,9 @@ 31CB4251273AF50700FA0F3F /* SpeechRecognizerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CB4250273AF50700FA0F3F /* SpeechRecognizerProtocol.swift */; }; 31CC224928369B38001654A4 /* AutofillLoginSettingsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC224828369B38001654A4 /* AutofillLoginSettingsListViewController.swift */; }; 31DD208427395A5A008FB313 /* VoiceSearchHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31DD208327395A5A008FB313 /* VoiceSearchHelper.swift */; }; + 31DE43C22C2C480D00F8C51F /* DuckPlayerFeaturePresentationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31DE43C12C2C480D00F8C51F /* DuckPlayerFeaturePresentationView.swift */; }; + 31DE43C42C2C60E800F8C51F /* DuckPlayerModalPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31DE43C32C2C60E800F8C51F /* DuckPlayerModalPresenter.swift */; }; + 31DE43C62C2DA70A00F8C51F /* DuckPlayer-ModalAnimation.json in Resources */ = {isa = PBXBuildFile; fileRef = 31DE43C52C2DA70A00F8C51F /* DuckPlayer-ModalAnimation.json */; }; 31E69A63280F4CB600478327 /* DuckUI in Frameworks */ = {isa = PBXBuildFile; productRef = 31E69A62280F4CB600478327 /* DuckUI */; }; 31EF52E1281B3BDC0034796E /* AutofillLoginListItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31EF52E0281B3BDC0034796E /* AutofillLoginListItemViewModel.swift */; }; 3712091E2C21E390003ADF3D /* RemoteMessagingStoreErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3712091D2C21E390003ADF3D /* RemoteMessagingStoreErrorHandling.swift */; }; @@ -247,10 +250,16 @@ 56D8556C2BEA91C4009F9698 /* SyncAlertsPresenting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D8556B2BEA91C4009F9698 /* SyncAlertsPresenting.swift */; }; 6AC6DAB328804F97002723C0 /* BarsAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC6DAB228804F97002723C0 /* BarsAnimator.swift */; }; 6AC98419288055C1005FA9CA /* BarsAnimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC98418288055C1005FA9CA /* BarsAnimatorTests.swift */; }; + 6F5CC0812C2AFFE400AFC840 /* ToggleExpandButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F5CC0802C2AFFE400AFC840 /* ToggleExpandButtonView.swift */; }; 6F655BE22BAB289E00AC3597 /* DefaultTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F655BE12BAB289E00AC3597 /* DefaultTheme.swift */; }; 6F8496412BC3D8EE00ADA54E /* OnboardingButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8496402BC3D8EE00ADA54E /* OnboardingButtonsView.swift */; }; + 6F96FF102C2B128500162692 /* NewTabPageCustomizeButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F96FF0F2C2B128500162692 /* NewTabPageCustomizeButtonView.swift */; }; 6FB1FE9E2C24D41D0075B68B /* NewTabPageSectionsDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB1FE9D2C24D41D0075B68B /* NewTabPageSectionsDebugView.swift */; }; 6FB1FEA22C256ACD0075B68B /* NewTabPageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB1FEA12C256ACD0075B68B /* NewTabPageManager.swift */; }; + 6FB2A67A2C2C5BAE004D20C8 /* FavoriteEmptyStateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB2A6792C2C5BAE004D20C8 /* FavoriteEmptyStateItem.swift */; }; + 6FB2A67C2C2D9DF0004D20C8 /* FavoritesEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB2A67B2C2D9DF0004D20C8 /* FavoritesEmptyStateView.swift */; }; + 6FB2A67E2C2DAFB4004D20C8 /* NewTabPageGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB2A67D2C2DAFB4004D20C8 /* NewTabPageGridView.swift */; }; + 6FB2A6802C2EA950004D20C8 /* FavoritesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB2A67F2C2EA950004D20C8 /* FavoritesModel.swift */; }; 6FBF0F8B2BD7C0A900136CF0 /* AllProtectedCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBF0F8A2BD7C0A900136CF0 /* AllProtectedCell.swift */; }; 6FD1BAE42B87A107000C475C /* AdAttributionPixelReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD1BAE12B87A107000C475C /* AdAttributionPixelReporter.swift */; }; 6FD1BAE52B87A107000C475C /* AdAttributionReporterStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD1BAE22B87A107000C475C /* AdAttributionReporterStorage.swift */; }; @@ -258,10 +267,15 @@ 6FD3AEE32B8F4EEB0060FCCC /* AdAttributionPixelReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD3AEE12B8DFBB80060FCCC /* AdAttributionPixelReporterTests.swift */; }; 6FDA1FB32B59584400AC962A /* AddressDisplayHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDA1FB22B59584400AC962A /* AddressDisplayHelper.swift */; }; 6FDB3F192BD11A4400F7A307 /* AutocompleteSuggestionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDB3F182BD11A4400F7A307 /* AutocompleteSuggestionsModel.swift */; }; + 6FE018402C25CB3F001F680D /* FavoritesSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE0183F2C25CB3F001F680D /* FavoritesSectionHeader.swift */; }; 6FE095D82BD90AFB00490FF8 /* UniversalOmniBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE095D72BD90AFB00490FF8 /* UniversalOmniBarState.swift */; }; 6FE127382C20492500EB5724 /* NewTabPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE127372C20492500EB5724 /* NewTabPage.swift */; }; 6FE1273A2C204BD000EB5724 /* NewTabPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE127392C204BD000EB5724 /* NewTabPageView.swift */; }; + 6FE1273D2C204C2500EB5724 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE1273C2C204C2500EB5724 /* FavoritesView.swift */; }; + 6FE127402C204D9B00EB5724 /* ShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE1273F2C204D9B00EB5724 /* ShortcutsView.swift */; }; + 6FE127432C204DF700EB5724 /* FavoriteItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE127422C204DF700EB5724 /* FavoriteItemView.swift */; }; 6FE127462C2054A900EB5724 /* NewTabPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE127452C2054A900EB5724 /* NewTabPageViewController.swift */; }; + 6FE1274B2C20943500EB5724 /* ShortcutItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE1274A2C20943500EB5724 /* ShortcutItemView.swift */; }; 6FF915822B88E07A0042AC87 /* AdAttributionFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */; }; 7BC571202BDBB877003B0CCE /* VPNActivationDateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */; }; 7BC571212BDBB977003B0CCE /* VPNActivationDateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */; }; @@ -499,6 +513,8 @@ 9833913727AC400800DAF119 /* AppTrackerDataSetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9833913627AC400800DAF119 /* AppTrackerDataSetProvider.swift */; }; 9838059F2228208E00385F1A /* PositiveFeedbackViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9838059E2228208E00385F1A /* PositiveFeedbackViewController.swift */; }; 983BD6B52B34760600AAC78E /* MockPrivacyConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B0F6412AB08BE9001EAF05 /* MockPrivacyConfiguration.swift */; }; + 983C52E42C2C050B007B5747 /* BookmarksStateRepair.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983C52E32C2C050B007B5747 /* BookmarksStateRepair.swift */; }; + 983C52E72C2C0ACB007B5747 /* BookmarkStateRepairTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983C52E52C2C0ABA007B5747 /* BookmarkStateRepairTests.swift */; }; 983D71B12A286E810072E26D /* SyncDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983D71B02A286E810072E26D /* SyncDebugViewController.swift */; }; 983EABB8236198F6003948D1 /* DatabaseMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983EABB7236198F6003948D1 /* DatabaseMigration.swift */; }; 984147A824F0259000362052 /* Onboarding.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 984147AA24F0259000362052 /* Onboarding.storyboard */; }; @@ -1272,6 +1288,9 @@ 31CB4250273AF50700FA0F3F /* SpeechRecognizerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizerProtocol.swift; sourceTree = ""; }; 31CC224828369B38001654A4 /* AutofillLoginSettingsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginSettingsListViewController.swift; sourceTree = ""; }; 31DD208327395A5A008FB313 /* VoiceSearchHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSearchHelper.swift; sourceTree = ""; }; + 31DE43C12C2C480D00F8C51F /* DuckPlayerFeaturePresentationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerFeaturePresentationView.swift; sourceTree = ""; }; + 31DE43C32C2C60E800F8C51F /* DuckPlayerModalPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerModalPresenter.swift; sourceTree = ""; }; + 31DE43C52C2DA70A00F8C51F /* DuckPlayer-ModalAnimation.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "DuckPlayer-ModalAnimation.json"; sourceTree = ""; }; 31EF52E0281B3BDC0034796E /* AutofillLoginListItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginListItemViewModel.swift; sourceTree = ""; }; 3712091D2C21E390003ADF3D /* RemoteMessagingStoreErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMessagingStoreErrorHandling.swift; sourceTree = ""; }; 372A0FEF2B2389590033BF7F /* SyncMetricsEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMetricsEventsHandler.swift; sourceTree = ""; }; @@ -1335,11 +1354,17 @@ 56D8556B2BEA91C4009F9698 /* SyncAlertsPresenting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAlertsPresenting.swift; sourceTree = ""; }; 6AC6DAB228804F97002723C0 /* BarsAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarsAnimator.swift; sourceTree = ""; }; 6AC98418288055C1005FA9CA /* BarsAnimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarsAnimatorTests.swift; sourceTree = ""; }; + 6F5CC0802C2AFFE400AFC840 /* ToggleExpandButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleExpandButtonView.swift; sourceTree = ""; }; 6F655BE12BAB289E00AC3597 /* DefaultTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTheme.swift; sourceTree = ""; }; 6F8496402BC3D8EE00ADA54E /* OnboardingButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingButtonsView.swift; sourceTree = ""; }; + 6F96FF0F2C2B128500162692 /* NewTabPageCustomizeButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageCustomizeButtonView.swift; sourceTree = ""; }; 6FB030C7234331B400A10DB9 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Configuration.xcconfig; path = Configuration/Configuration.xcconfig; sourceTree = ""; }; 6FB1FE9D2C24D41D0075B68B /* NewTabPageSectionsDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageSectionsDebugView.swift; sourceTree = ""; }; 6FB1FEA12C256ACD0075B68B /* NewTabPageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageManager.swift; sourceTree = ""; }; + 6FB2A6792C2C5BAE004D20C8 /* FavoriteEmptyStateItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteEmptyStateItem.swift; sourceTree = ""; }; + 6FB2A67B2C2D9DF0004D20C8 /* FavoritesEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesEmptyStateView.swift; sourceTree = ""; }; + 6FB2A67D2C2DAFB4004D20C8 /* NewTabPageGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageGridView.swift; sourceTree = ""; }; + 6FB2A67F2C2EA950004D20C8 /* FavoritesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesModel.swift; sourceTree = ""; }; 6FBF0F8A2BD7C0A900136CF0 /* AllProtectedCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllProtectedCell.swift; sourceTree = ""; }; 6FD1BAE12B87A107000C475C /* AdAttributionPixelReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AdAttributionPixelReporter.swift; path = AdAttribution/AdAttributionPixelReporter.swift; sourceTree = ""; }; 6FD1BAE22B87A107000C475C /* AdAttributionReporterStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AdAttributionReporterStorage.swift; path = AdAttribution/AdAttributionReporterStorage.swift; sourceTree = ""; }; @@ -1347,10 +1372,15 @@ 6FD3AEE12B8DFBB80060FCCC /* AdAttributionPixelReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdAttributionPixelReporterTests.swift; sourceTree = ""; }; 6FDA1FB22B59584400AC962A /* AddressDisplayHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressDisplayHelper.swift; sourceTree = ""; }; 6FDB3F182BD11A4400F7A307 /* AutocompleteSuggestionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteSuggestionsModel.swift; sourceTree = ""; }; + 6FE0183F2C25CB3F001F680D /* FavoritesSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesSectionHeader.swift; sourceTree = ""; }; 6FE095D72BD90AFB00490FF8 /* UniversalOmniBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalOmniBarState.swift; sourceTree = ""; }; 6FE127372C20492500EB5724 /* NewTabPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPage.swift; sourceTree = ""; }; 6FE127392C204BD000EB5724 /* NewTabPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageView.swift; sourceTree = ""; }; + 6FE1273C2C204C2500EB5724 /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = ""; }; + 6FE1273F2C204D9B00EB5724 /* ShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsView.swift; sourceTree = ""; }; + 6FE127422C204DF700EB5724 /* FavoriteItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteItemView.swift; sourceTree = ""; }; 6FE127452C2054A900EB5724 /* NewTabPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageViewController.swift; sourceTree = ""; }; + 6FE1274A2C20943500EB5724 /* ShortcutItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutItemView.swift; sourceTree = ""; }; 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdAttributionFetcherTests.swift; sourceTree = ""; }; 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VPNActivationDateStore.swift; sourceTree = ""; }; 83004E7F2193BB8200DA013C /* WKNavigationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKNavigationExtension.swift; sourceTree = ""; }; @@ -1648,6 +1678,8 @@ 983A4B8F251EABEA00F3EDF1 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/InfoPlist.strings; sourceTree = ""; }; 983A4B90251EABEA00F3EDF1 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = ""; }; 983A4B91251EABEA00F3EDF1 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/InfoPlist.strings; sourceTree = ""; }; + 983C52E32C2C050B007B5747 /* BookmarksStateRepair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksStateRepair.swift; sourceTree = ""; }; + 983C52E52C2C0ABA007B5747 /* BookmarkStateRepairTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkStateRepairTests.swift; sourceTree = ""; }; 983D71B02A286E810072E26D /* SyncDebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDebugViewController.swift; sourceTree = ""; }; 983E1349251EABF200149BD9 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; 983E134A251EABF200149BD9 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -3197,6 +3229,24 @@ name = Helper; sourceTree = ""; }; + 31DE43C72C2DAA7F00F8C51F /* Resources */ = { + isa = PBXGroup; + children = ( + 31BC5F402C2B0B540004DF37 /* DuckPlayer.xcassets */, + 31DE43C52C2DA70A00F8C51F /* DuckPlayer-ModalAnimation.json */, + ); + path = Resources; + sourceTree = ""; + }; + 31DE43C82C2DAA8F00F8C51F /* Modal */ = { + isa = PBXGroup; + children = ( + 31DE43C12C2C480D00F8C51F /* DuckPlayerFeaturePresentationView.swift */, + 31DE43C32C2C60E800F8C51F /* DuckPlayerModalPresenter.swift */, + ); + path = Modal; + sourceTree = ""; + }; 31E69A60280F4BAD00478327 /* LocalPackages */ = { isa = PBXGroup; children = ( @@ -3335,6 +3385,16 @@ name = NewTabPageSectionsDebugView; sourceTree = ""; }; + 6FB2A6782C2C5B9E004D20C8 /* EmptyState */ = { + isa = PBXGroup; + children = ( + 6FE0183F2C25CB3F001F680D /* FavoritesSectionHeader.swift */, + 6FB2A6792C2C5BAE004D20C8 /* FavoriteEmptyStateItem.swift */, + 6FB2A67B2C2D9DF0004D20C8 /* FavoritesEmptyStateView.swift */, + ); + name = EmptyState; + sourceTree = ""; + }; 6FD1BAE02B87A0E8000C475C /* AdAttribution */ = { isa = PBXGroup; children = ( @@ -3352,10 +3412,43 @@ 6FE127392C204BD000EB5724 /* NewTabPageView.swift */, 6FE127452C2054A900EB5724 /* NewTabPageViewController.swift */, 6FB1FEA12C256ACD0075B68B /* NewTabPageManager.swift */, + 6FE1273B2C204C0D00EB5724 /* Views */, ); name = HomeRedesign; sourceTree = ""; }; + 6FE1273B2C204C0D00EB5724 /* Views */ = { + isa = PBXGroup; + children = ( + 6FE127472C20941A00EB5724 /* Shortcuts */, + 6FE127412C204DE900EB5724 /* Favorites */, + 6F5CC0802C2AFFE400AFC840 /* ToggleExpandButtonView.swift */, + 6F96FF0F2C2B128500162692 /* NewTabPageCustomizeButtonView.swift */, + 6FB2A67D2C2DAFB4004D20C8 /* NewTabPageGridView.swift */, + ); + name = Views; + sourceTree = ""; + }; + 6FE127412C204DE900EB5724 /* Favorites */ = { + isa = PBXGroup; + children = ( + 6FB2A6782C2C5B9E004D20C8 /* EmptyState */, + 6FE1273C2C204C2500EB5724 /* FavoritesView.swift */, + 6FE127422C204DF700EB5724 /* FavoriteItemView.swift */, + 6FB2A67F2C2EA950004D20C8 /* FavoritesModel.swift */, + ); + name = Favorites; + sourceTree = ""; + }; + 6FE127472C20941A00EB5724 /* Shortcuts */ = { + isa = PBXGroup; + children = ( + 6FE1273F2C204D9B00EB5724 /* ShortcutsView.swift */, + 6FE1274A2C20943500EB5724 /* ShortcutItemView.swift */, + ); + name = Shortcuts; + sourceTree = ""; + }; 6FF9157F2B88E04F0042AC87 /* AdAttribution */ = { isa = PBXGroup; children = ( @@ -4239,11 +4332,12 @@ C14882DD27F20D7300D59F0C /* Bookmarks */ = { isa = PBXGroup; children = ( + C14882DE27F20D7E00D59F0C /* ImportExport */, 987130BD294AAB8200AB05E0 /* BSK */, 98AAF8E3292EB46000DBDF06 /* BookmarksMigrationTests.swift */, 98983095255B5019003339A2 /* BookmarksCachingSearchTests.swift */, 98629D322C21BDEB001E6031 /* BookmarksStateValidationTests.swift */, - C14882DE27F20D7E00D59F0C /* ImportExport */, + 983C52E52C2C0ABA007B5747 /* BookmarkStateRepairTests.swift */, ); name = Bookmarks; sourceTree = ""; @@ -4431,13 +4525,14 @@ D63FF8922C1B67D1006DE24D /* DuckPlayer */ = { isa = PBXGroup; children = ( + 31DE43C82C2DAA8F00F8C51F /* Modal */, + 31DE43C72C2DAA7F00F8C51F /* Resources */, D63FF8972C1B6A45006DE24D /* DuckPlayer.swift */, D62EC3C12C248AF800FC9D04 /* DuckNavigationHandling.swift */, D63FF8892C1B21C2006DE24D /* YouTubePlayerNavigationHandler.swift */, D63FF88B2C1B21ED006DE24D /* DuckPlayerURLExtension.swift */, D63FF8942C1B67E8006DE24D /* YoutubeOverlayUserScript.swift */, D63FF8932C1B67E8006DE24D /* YoutubePlayerUserScript.swift */, - 31BC5F402C2B0B540004DF37 /* DuckPlayer.xcassets */, ); path = DuckPlayer; sourceTree = ""; @@ -5289,6 +5384,7 @@ 9856A1982933D2EB00ACB44F /* BookmarksModelsErrorHandling.swift */, 379E877329E97C8D001C8BB0 /* BookmarksCleanupErrorHandling.swift */, 98629D302C21765A001E6031 /* BookmarksStateValidation.swift */, + 983C52E32C2C050B007B5747 /* BookmarksStateRepair.swift */, C14882D627F2010700D59F0C /* ImportExport */, F1CE42A81ECA0A660074A8DF /* LegacyStore */, ); @@ -6050,6 +6146,7 @@ 8524AAAC2A3888FE00EEC6D2 /* Waitlist.xcassets in Resources */, 982686B92600C0960011A8D6 /* ActionMessageView.xib in Resources */, F4F7F10A25813FE200045D62 /* 01_Fire_really_small.json in Resources */, + 31DE43C62C2DA70A00F8C51F /* DuckPlayer-ModalAnimation.json in Resources */, 1E0A75EA27A2FBD000A2BFB6 /* Downloads.storyboard in Resources */, 8517D98B221783A0006A8DD0 /* FindInPage.xcassets in Resources */, 984147C924F02E9E00362052 /* DaxOnboarding.storyboard in Resources */, @@ -6349,6 +6446,7 @@ C17B59592A03AAD30055F2D1 /* PasswordGenerationPromptViewModel.swift in Sources */, D65625A12C232F5E006EF297 /* SettingsDuckPlayerView.swift in Sources */, D6FEB8B52B74994000C3615F /* HeadlessWebViewCoordinator.swift in Sources */, + 6FE1273D2C204C2500EB5724 /* FavoritesView.swift in Sources */, 8528AE81212F15D600D0BD74 /* AppRatingPrompt.xcdatamodeld in Sources */, 1E24295E293F57FA00584836 /* LottieView.swift in Sources */, 8577A1C5255D2C0D00D43FCD /* HitTestingToolbar.swift in Sources */, @@ -6381,9 +6479,11 @@ C1CDA3162AFB9C7F006D1476 /* AutofillNeverPromptWebsitesManager.swift in Sources */, D668D9272B6937D2008E2FF2 /* SubscriptionITPViewModel.swift in Sources */, F1CA3C371F045878005FADB3 /* PrivacyStore.swift in Sources */, + 31DE43C42C2C60E800F8C51F /* DuckPlayerModalPresenter.swift in Sources */, 37FCAAC029930E26000E420A /* FailedAssertionView.swift in Sources */, 4BBBBA922B03291700D965DA /* VPNWaitlistUserText.swift in Sources */, F4E1936625AF722F001D2666 /* HighlightCutOutView.swift in Sources */, + 6FB2A67C2C2D9DF0004D20C8 /* FavoritesEmptyStateView.swift in Sources */, 1E162605296840D80004127F /* Triangle.swift in Sources */, B609D5522862EAFF0088CAC2 /* InlineWKDownloadDelegate.swift in Sources */, BDFF03222BA3D8E200F324C9 /* NetworkProtectionFeatureVisibility.swift in Sources */, @@ -6453,6 +6553,7 @@ CBEFB9142AE0844700DEDE7B /* CriticalAlerts.swift in Sources */, 986B16C425E92DF0007D23E8 /* BrowsingMenuViewController.swift in Sources */, 988AC355257E47C100793C64 /* RequeryLogic.swift in Sources */, + 6FB2A67A2C2C5BAE004D20C8 /* FavoriteEmptyStateItem.swift in Sources */, 6FBF0F8B2BD7C0A900136CF0 /* AllProtectedCell.swift in Sources */, 1E4F4A5A297193DE00625985 /* MainViewController+CookiesManaged.swift in Sources */, 8586A10D24CBA7070049720E /* FindInPageActivity.swift in Sources */, @@ -6507,6 +6608,7 @@ 9FA5E44B2BF1AF3400BDEF02 /* SubscriptionContainerViewFactory.swift in Sources */, C1F341C72A6924100032057B /* EmailAddressPromptViewModel.swift in Sources */, F47E53D9250A97330037C686 /* OnboardingDefaultBroswerViewController.swift in Sources */, + 6FE127432C204DF700EB5724 /* FavoriteItemView.swift in Sources */, F13B4BD51F183B3600814661 /* TabsModelPersistenceExtension.swift in Sources */, 980891A52237D4F500313A70 /* FeedbackNavigator.swift in Sources */, 1E8AD1C927BFAD1500ABA377 /* DirectoryMonitor.swift in Sources */, @@ -6553,7 +6655,9 @@ 319A37172829C8AD0079FBCE /* UITableViewExtension.swift in Sources */, 85EE7F59224673C5000FE757 /* WebContainerNavigationController.swift in Sources */, 6FD1BAE52B87A107000C475C /* AdAttributionReporterStorage.swift in Sources */, + 6F5CC0812C2AFFE400AFC840 /* ToggleExpandButtonView.swift in Sources */, D68A21462B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift in Sources */, + 31DE43C22C2C480D00F8C51F /* DuckPlayerFeaturePresentationView.swift in Sources */, F4C9FBF528340DDA002281CC /* AutofillInterfaceEmailTruncator.swift in Sources */, 1E016AB42949FEB500F21625 /* OmniBarNotificationViewModel.swift in Sources */, 6AC6DAB328804F97002723C0 /* BarsAnimator.swift in Sources */, @@ -6648,6 +6752,7 @@ C17B595A2A03AAD30055F2D1 /* PasswordGenerationPromptViewController.swift in Sources */, 8531A08E1F9950E6000484F0 /* UnprotectedSitesViewController.swift in Sources */, CBD4F13C279EBF4A00B20FD7 /* HomeMessage.swift in Sources */, + 6FB2A67E2C2DAFB4004D20C8 /* NewTabPageGridView.swift in Sources */, 1DEAADEC2BA45B4500E25A97 /* SettingsAccessibilityView.swift in Sources */, 85C8E61D2B0E47380029A6BD /* BookmarksDatabaseSetup.swift in Sources */, 3132FA2C27A07A1B00DD7A12 /* FilePreview.swift in Sources */, @@ -6673,6 +6778,7 @@ D6FEB8B12B7498A300C3615F /* HeadlessWebView.swift in Sources */, F1FDC9352BF51E41006B1435 /* VPNSettings+Environment.swift in Sources */, 850ABD012AC3961100A733DF /* MainViewController+Segues.swift in Sources */, + 6FB2A6802C2EA950004D20C8 /* FavoritesModel.swift in Sources */, 9817C9C321EF594700884F65 /* AutoClear.swift in Sources */, 9821234E2B6D0A6300F08C57 /* UserAuthenticator.swift in Sources */, 310C4B47281B60E300BA79A9 /* AutofillLoginDetailsViewModel.swift in Sources */, @@ -6688,6 +6794,7 @@ 854A012B2A54412600FCC628 /* ActivityViewController.swift in Sources */, F1CA3C391F045885005FADB3 /* PrivacyUserDefaults.swift in Sources */, 6F655BE22BAB289E00AC3597 /* DefaultTheme.swift in Sources */, + 6FE1274B2C20943500EB5724 /* ShortcutItemView.swift in Sources */, BD862E072B30F5E30073E2EE /* VPNFeedbackSender.swift in Sources */, AA4D6A6A23DB87B1007E8790 /* AppIconManager.swift in Sources */, 8563A03C1F9288D600F04442 /* BrowserChromeManager.swift in Sources */, @@ -6698,6 +6805,7 @@ 85058368219C49E000ED4EDB /* HomeViewSectionRenderers.swift in Sources */, 1DEAADEE2BA45DFE00E25A97 /* SettingsDataClearingView.swift in Sources */, D65625902C22D307006EF297 /* DuckPlayerURLExtension.swift in Sources */, + 6F96FF102C2B128500162692 /* NewTabPageCustomizeButtonView.swift in Sources */, EE01EB432AFC1E0A0096AAC9 /* NetworkProtectionVPNLocationView.swift in Sources */, 7BC571202BDBB877003B0CCE /* VPNActivationDateStore.swift in Sources */, 9820EAF522613CD30089094D /* WebProgressWorker.swift in Sources */, @@ -6738,6 +6846,7 @@ 1E7A71172934EB6400B7EA19 /* OmniBarNotificationAnimator.swift in Sources */, 85C2971A248162CA0063A335 /* DaxOnboardingPadViewController.swift in Sources */, F4F6DFB826EA9AA600ED7E12 /* BookmarksTextFieldCell.swift in Sources */, + 6FE127402C204D9B00EB5724 /* ShortcutsView.swift in Sources */, 85F98F92296F32BD00742F4A /* SyncSettingsViewController.swift in Sources */, 84E341961E2F7EFB00BDBA6F /* AppDelegate.swift in Sources */, 310D091D2799F57200DC0060 /* Download.swift in Sources */, @@ -6806,6 +6915,7 @@ D6E0C1892B7A2E0D00D5E1E9 /* DesktopDownloadViewModel.swift in Sources */, 8524CC9A246DA81700E59D45 /* FullscreenDaxDialogViewController.swift in Sources */, CBBB9A192BED441400BEAC71 /* PixelExperimentForBrokenSites.swift in Sources */, + 6FE018402C25CB3F001F680D /* FavoritesSectionHeader.swift in Sources */, F17669D71E43401C003D3222 /* MainViewController.swift in Sources */, 6FE127462C2054A900EB5724 /* NewTabPageViewController.swift in Sources */, 984D60B2222A1284003B9E3B /* FeedbackFormViewController.swift in Sources */, @@ -6844,6 +6954,7 @@ 9FA5E44E2BF1B16400BDEF02 /* SubscriptionContainerViewModelTests.swift in Sources */, 85BA58581F34F72F00C6E8CA /* AppUserDefaultsTests.swift in Sources */, F1134EBC1F40D45700B73467 /* MockStatisticsStore.swift in Sources */, + 983C52E72C2C0ACB007B5747 /* BookmarkStateRepairTests.swift in Sources */, 31C138AC27A403CB00FFD4B2 /* DownloadManagerTests.swift in Sources */, EEFE9C732A603CE9005B0A26 /* NetworkProtectionStatusViewModelTests.swift in Sources */, F13B4BF91F18CA0600814661 /* TabsModelTests.swift in Sources */, @@ -7138,6 +7249,7 @@ 1E4DCF4C27B6A4CB00961E25 /* URLFileExtension.swift in Sources */, EE50053029C3BA0800AE0773 /* InternalUserStore.swift in Sources */, F1D477CB1F2149C40031ED49 /* Type.swift in Sources */, + 983C52E42C2C050B007B5747 /* BookmarksStateRepair.swift in Sources */, 1E05D1D629C46EBB00BF9A1F /* DailyPixel.swift in Sources */, 1CB7B82123CEA1F800AA24EA /* DateExtension.swift in Sources */, 379E877429E97C8D001C8BB0 /* BookmarksCleanupErrorHandling.swift in Sources */, @@ -8017,7 +8129,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -8054,7 +8166,7 @@ CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -8144,7 +8256,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -8171,7 +8283,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -8320,7 +8432,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 = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -8345,7 +8457,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 = 0; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; INFOPLIST_FILE = DuckDuckGo/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -8414,7 +8526,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -8448,7 +8560,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -8481,7 +8593,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -8511,7 +8623,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -8821,7 +8933,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 = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -8852,7 +8964,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -8880,7 +8992,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -8913,7 +9025,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -8943,7 +9055,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProviderAlpha.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -8976,11 +9088,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 0; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -9213,7 +9325,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 = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9240,7 +9352,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9272,7 +9384,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9309,7 +9421,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9344,7 +9456,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9379,11 +9491,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 0; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -9556,11 +9668,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 0; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -9589,10 +9701,10 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 0; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 0; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -9794,7 +9906,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 161.1.2; + version = 164.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 d8ca804f52..17f922a2ea 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" : "cde956dec771bb7196ab3f4b07e00254e433d58d", - "version" : "161.1.2" + "revision" : "912001a8676345e96a8be360d5a6e3dca6d8e0ec", + "version" : "164.2.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "4689746e42b24c40c18896d697ea02b854e90d35", - "version" : "5.21.0" + "revision" : "7ac68ae3bc052fa59adbc1ba8fd5cb5849a6bc99", + "version" : "5.25.0" } }, { diff --git a/DuckDuckGo/AddressDisplayHelper.swift b/DuckDuckGo/AddressDisplayHelper.swift index 0016af4702..a65a4e7a09 100644 --- a/DuckDuckGo/AddressDisplayHelper.swift +++ b/DuckDuckGo/AddressDisplayHelper.swift @@ -24,18 +24,24 @@ extension OmniBar { struct AddressDisplayHelper { static func addressForDisplay(url: URL, showsFullURL: Bool) -> NSAttributedString { - + + if url.isDuckPlayer, + let playerURL = getDuckPlayerURL(url: url, showsFullURL: showsFullURL) { + return playerURL + } + if !showsFullURL, let shortAddress = shortURLString(url) { return NSAttributedString( string: shortAddress, attributes: [.foregroundColor: ThemeManager.shared.currentTheme.searchBarTextColor]) + } else { return deemphasisePath(forUrl: url) } } static func deemphasisePath(forUrl url: URL) -> NSAttributedString { - + let s = url.absoluteString let attributedString = NSMutableAttributedString(string: s) guard let c = URLComponents(url: url, resolvingAgainstBaseURL: true) else { @@ -72,5 +78,20 @@ extension OmniBar { return url.host?.droppingWwwPrefix() } + + private static func getDuckPlayerURL(url: URL, showsFullURL: Bool) -> NSAttributedString? { + if !showsFullURL { + return NSAttributedString( + string: UserText.duckPlayerFeatureName, + attributes: [.foregroundColor: ThemeManager.shared.currentTheme.searchBarTextColor]) + } else { + if let (videoID, _) = url.youtubeVideoParams { + return NSAttributedString( + string: URL.duckPlayer(videoID).absoluteString, + attributes: [.foregroundColor: ThemeManager.shared.currentTheme.searchBarTextColor]) + } + } + return nil + } } } diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 81b50dd257..ef4149ff99 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -344,6 +344,8 @@ import WebKit AppDependencyProvider.shared.userBehaviorMonitor.handleAction(.reopenApp) + AppDependencyProvider.shared.subscriptionManager.loadInitialData() + setUpAutofillPixelReporter() return true @@ -460,6 +462,7 @@ import WebKit } } + // swiftlint:disable:next function_body_length func applicationDidBecomeActive(_ application: UIApplication) { guard !testing else { return } @@ -509,8 +512,6 @@ import WebKit #if NETWORK_PROTECTION widgetRefreshModel.refreshVPNWidget() - stopTunnelAndShowThankYouMessagingIfNeeded() - if tunnelDefaults.showEntitlementAlert { presentExpiredEntitlementAlert() } @@ -518,52 +519,30 @@ import WebKit presentExpiredEntitlementNotificationIfNeeded() Task { + await stopAndRemoveVPNIfNotAuthenticated() await refreshShortcuts() await vpnWorkaround.installRedditSessionWorkaround() } #endif - updateSubscriptionStatus() + AppDependencyProvider.shared.subscriptionManager.refreshCachedSubscriptionAndEntitlements { isSubscriptionActive in + if isSubscriptionActive { + DailyPixel.fire(pixel: .privacyProSubscriptionActive) + } + } let importPasswordsStatusHandler = ImportPasswordsStatusHandler(syncService: syncService) importPasswordsStatusHandler.checkSyncSuccessStatus() } - private func stopTunnelAndShowThankYouMessagingIfNeeded() { - if accountManager.isUserAuthenticated { - return - } - - if AppDependencyProvider.shared.vpnFeatureVisibility.isPrivacyProLaunched() && !accountManager.isUserAuthenticated { - Task { - await self.stopAndRemoveVPN(with: "subscription-check") - } - } - } - - private func stopAndRemoveVPN(with reason: String) async { - guard await AppDependencyProvider.shared.networkProtectionTunnelController.isInstalled else { + private func stopAndRemoveVPNIfNotAuthenticated() async { + // Only remove the VPN if the user is not authenticated, and it's installed: + guard !accountManager.isUserAuthenticated, await AppDependencyProvider.shared.networkProtectionTunnelController.isInstalled else { return } await AppDependencyProvider.shared.networkProtectionTunnelController.stop() - await AppDependencyProvider.shared.networkProtectionTunnelController.removeVPN() - } - - func updateSubscriptionStatus() { - Task { - guard let token = accountManager.accessToken else { return } - var subscriptionService: SubscriptionEndpointService { - AppDependencyProvider.shared.subscriptionManager.subscriptionEndpointService - } - if case .success(let subscription) = await subscriptionService.getSubscription(accessToken: token, - cachePolicy: .reloadIgnoringLocalCacheData) { - if subscription.isActive { - DailyPixel.fire(pixel: .privacyProSubscriptionActive) - } - } - await accountManager.fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData) - } + await AppDependencyProvider.shared.networkProtectionTunnelController.removeVPN(reason: .didBecomeActiveCheck) } func applicationWillResignActive(_ application: UIApplication) { diff --git a/DuckDuckGo/AppUserDefaults.swift b/DuckDuckGo/AppUserDefaults.swift index 971b7d3090..51d060f184 100644 --- a/DuckDuckGo/AppUserDefaults.swift +++ b/DuckDuckGo/AppUserDefaults.swift @@ -39,6 +39,7 @@ public class AppUserDefaults: AppSettings { public static let addressBarPositionChanged = Notification.Name("com.duckduckgo.app.AddressBarPositionChanged") public static let showsFullURLAddressSettingChanged = Notification.Name("com.duckduckgo.app.ShowsFullURLAddressSettingChanged") public static let autofillDebugScriptToggled = Notification.Name("com.duckduckgo.app.DidToggleAutofillDebugScript") + public static let duckPlayerModeChanged = Notification.Name("com.duckduckgo.app.DuckPlayerModeChanged") } private let groupName: String @@ -389,9 +390,11 @@ public class AppUserDefaults: AppSettings { return .alwaysAsk } set { - // Here we set both the DuckPlayer mode and the overlayInteracte + // Here we set both the DuckPlayer mode and the duckPlayerAskModeOverlayHidden userDefaults?.set(newValue.stringValue, forKey: Keys.duckPlayerMode) userDefaults?.set(false, forKey: Keys.duckPlayerAskModeOverlayHidden) + NotificationCenter.default.post(name: AppUserDefaults.Notifications.duckPlayerModeChanged, + object: duckPlayerMode) } } } diff --git a/DuckDuckGo/BookmarksDatabaseSetup.swift b/DuckDuckGo/BookmarksDatabaseSetup.swift index a05de2ce9d..93364b97ba 100644 --- a/DuckDuckGo/BookmarksDatabaseSetup.swift +++ b/DuckDuckGo/BookmarksDatabaseSetup.swift @@ -72,6 +72,7 @@ struct BookmarksDatabaseSetup { let contextForValidation = bookmarksDatabase.makeContext(concurrencyType: .privateQueueConcurrencyType) contextForValidation.performAndWait { validator.validateBookmarksStructure(context: contextForValidation) + repairDeletedFlag(context: contextForValidation) } if migrationHappened { @@ -84,7 +85,24 @@ struct BookmarksDatabaseSetup { return migrationHappened } - + + private func repairDeletedFlag(context: NSManagedObjectContext) { + let stateRepair = BookmarksStateRepair(keyValueStore: UserDefaults.app) + let status = stateRepair.validateAndRepairPendingDeletionState(in: context) + switch status { + case .alreadyPerformed, .noBrokenData: + break + case .dataRepaired: + Pixel.fire(pixel: .debugBookmarksPendingDeletionFixed) + case .repairError(let underlyingError): + let processedErrors = CoreDataErrorsParser.parse(error: underlyingError as NSError) + + DailyPixel.fireDailyAndCount(pixel: .debugBookmarksPendingDeletionRepairError, + withAdditionalParameters: processedErrors.errorPixelParameters, + includedParameters: [.appVersion]) + } + } + private func migrateToFormFactorSpecificFavorites(_ context: NSManagedObjectContext, _ oldFavoritesOrder: [String]?) -> Bool { do { BookmarkFormFactorFavoritesMigration.migrateToFormFactorSpecificFavorites(byCopyingExistingTo: .mobile, diff --git a/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift b/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift index a854668e41..809889a797 100644 --- a/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift +++ b/DuckDuckGo/DuckPlayer/DuckNavigationHandling.swift @@ -23,11 +23,12 @@ protocol DuckNavigationHandling { func handleNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView, completion: @escaping (WKNavigationActionPolicy) -> Void) - func handleRedirect(url: URL?, webView: WKWebView) - func handleRedirect(_ navigationAction: WKNavigationAction, - completion: @escaping (WKNavigationActionPolicy) -> Void, - webView: WKWebView) - func goBack(webView: WKWebView) + func handleURLChange(url: URL?, webView: WKWebView) + func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, + completion: @escaping (WKNavigationActionPolicy) -> Void, + webView: WKWebView) + func handleGoBack(webView: WKWebView) + func handleReload(webView: WKWebView) } extension WKWebView { diff --git a/DuckDuckGo/DuckPlayer/DuckPlayer.swift b/DuckDuckGo/DuckPlayer/DuckPlayer.swift index bbec3ff700..8dde9d1fbd 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayer.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayer.swift @@ -30,7 +30,7 @@ enum DuckPlayerMode: Equatable, Codable, CustomStringConvertible, CaseIterable { private static let enabledString = "enabled" private static let alwaysAskString = "alwaysAsk" - private static let neverString = "alwaysAsk" + private static let neverString = "disabled" var description: String { switch self { @@ -99,7 +99,7 @@ public struct UserValues: Codable { final class DuckPlayerSettings { - private var appSettings: AppSettings + var appSettings: AppSettings init(appSettings: AppSettings = AppDependencyProvider.shared.appSettings) { self.appSettings = appSettings @@ -132,9 +132,12 @@ final class DuckPlayer { private var settings: DuckPlayerSettings - - init(settings: DuckPlayerSettings = DuckPlayerSettings()) { + @Published var userValues: UserValues + + init(settings: DuckPlayerSettings = DuckPlayerSettings(), userValues: UserValues? = nil) { self.settings = settings + self.userValues = userValues ?? UserValues(duckPlayerMode: settings.mode, askModeOverlayHidden: settings.askModeOverlayHidden) + registerForNotificationChanges() } // MARK: - Common Message Handlers @@ -144,8 +147,10 @@ final class DuckPlayer { assertionFailure("DuckPlayer: expected JSON representation of UserValues") return nil } + settings.mode = userValues.duckPlayerMode settings.askModeOverlayHidden = userValues.askModeOverlayHidden + return userValues } @@ -181,5 +186,21 @@ final class DuckPlayer { return InitialSetupSettings(userValues: userValues, settings: playerSettings) } + + private func registerForNotificationChanges() { + NotificationCenter.default.addObserver(self, + selector: #selector(updatePlayerMode), + name: AppUserDefaults.Notifications.duckPlayerModeChanged, + object: nil) + } + + + @objc private func updatePlayerMode(_ notification: Notification) { + userValues = encodeUserValues() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } } diff --git a/DuckDuckGo/Settings.xcassets/Images/SettingsDuckPlayer.imageset/Contents.json b/DuckDuckGo/DuckPlayer/DuckPlayer.xcassets/DuckPlayerIcon.imageset/Contents.json similarity index 100% rename from DuckDuckGo/Settings.xcassets/Images/SettingsDuckPlayer.imageset/Contents.json rename to DuckDuckGo/DuckPlayer/DuckPlayer.xcassets/DuckPlayerIcon.imageset/Contents.json diff --git a/DuckDuckGo/Settings.xcassets/Images/SettingsDuckPlayer.imageset/Video-Player-Color-24.svg b/DuckDuckGo/DuckPlayer/DuckPlayer.xcassets/DuckPlayerIcon.imageset/Video-Player-Color-24.svg similarity index 100% rename from DuckDuckGo/Settings.xcassets/Images/SettingsDuckPlayer.imageset/Video-Player-Color-24.svg rename to DuckDuckGo/DuckPlayer/DuckPlayer.xcassets/DuckPlayerIcon.imageset/Video-Player-Color-24.svg diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift b/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift index 2d13cac720..96f1b0eaeb 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerURLExtension.swift @@ -49,12 +49,12 @@ extension URL { var isDuckURLScheme: Bool { navigationalScheme == .duck } - + private var isYoutubeWatch: Bool { guard let host else { return false } return host.contains("youtube.com") && path == "/watch" } - + private var isYoutubeNoCookie: Bool { host == "www.youtube-nocookie.com" && pathComponents.count == 3 && pathComponents[safe: 1] == "embed" } diff --git a/DuckDuckGo/DuckPlayer/Modal/DuckPlayerFeaturePresentationView.swift b/DuckDuckGo/DuckPlayer/Modal/DuckPlayerFeaturePresentationView.swift new file mode 100644 index 0000000000..d72cef2ec5 --- /dev/null +++ b/DuckDuckGo/DuckPlayer/Modal/DuckPlayerFeaturePresentationView.swift @@ -0,0 +1,130 @@ +// +// DuckPlayerFeaturePresentationView.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DesignResourcesKit +import DuckUI +import Lottie + +struct DuckPlayerFeaturePresentationView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @Environment(\.verticalSizeClass) var verticalSizeClass + @State private var isAnimating: Bool = false + var dismisPresentation: (() -> Void)? + + var body: some View { + ZStack { + + VStack(alignment: .center, spacing: stackVerticalSpacing) { + animation + + Text(UserText.duckPlayerPresentationModalTitle) + .daxTitle2() + .multilineTextAlignment(.center) + .foregroundColor(Color(designSystemColor: .textPrimary)) + .minimumScaleFactor(Constants.textMinimumScaleFactor) + + Text(UserText.duckPlayerPresentationModalBody) + .daxBodyRegular() + .multilineTextAlignment(.center) + .foregroundColor(Color(designSystemColor: .textSecondary)) + .minimumScaleFactor(Constants.textMinimumScaleFactor) + + Spacer() + + Button(UserText.duckPlayerPresentationModalDismissButton, action: dismissButtonTapped) + .buttonStyle(PrimaryButtonStyle()) + .frame(maxWidth: Constants.buttonCTAMaxWidth) + } + .padding(.horizontal, Constants.horizontalPadding) + .padding(.top, contentVerticalPadding) + .padding(.bottom) + + VStack { + HStack { + Spacer() + Button(action: dismissButtonTapped) { + Image(systemName: Constants.closeButtonSystemImage) + .foregroundColor(Color(designSystemColor: .textPrimary)) + .daxBodyRegular() + .frame(width: Constants.closeButtonSize.width, height: Constants.closeButtonSize.height) + } + .padding(8) + } + Spacer() + } + } + .background(Color(designSystemColor: .backgroundSheets)) + } + + private func dismissButtonTapped() { + dismisPresentation?() + } + + @ViewBuilder + private var animation: some View { + LottieView(lottieFile: "DuckPlayer-ModalAnimation", + isAnimating: $isAnimating) + .frame(width: Constants.heroImageSize.width, height: Constants.heroImageSize.height) + .cornerRadius(8) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + Constants.animationDelay) { + isAnimating = true + } + } + } +} + +extension DuckPlayerFeaturePresentationView { + + enum Constants { + static let heroImageSize: CGSize = .init(width: 302, height: 180) + static let closeButtonSystemImage = "xmark" + static let closeButtonSize: CGSize = .init(width: 30, height: 30) + static let buttonCTAMaxWidth: CGFloat = 310 + static let textMinimumScaleFactor: CGFloat = 0.8 + static let horizontalPadding: CGFloat = 30 + static let animationDelay: Double = 2 + + } + + private var isSpaceConstrained: Bool { + verticalSizeClass == .compact + } + + private var stackVerticalSpacing: CGFloat { + if isSpaceConstrained { + return 5 + } else { + return 22 + } + } + + private var contentVerticalPadding: CGFloat { + if isSpaceConstrained { + return 4 + } else { + return 40 + } + } +} + +#Preview { + DuckPlayerFeaturePresentationView() +} diff --git a/DuckDuckGo/DuckPlayer/Modal/DuckPlayerModalPresenter.swift b/DuckDuckGo/DuckPlayer/Modal/DuckPlayerModalPresenter.swift new file mode 100644 index 0000000000..503e6af4e5 --- /dev/null +++ b/DuckDuckGo/DuckPlayer/Modal/DuckPlayerModalPresenter.swift @@ -0,0 +1,77 @@ +// +// DuckPlayerModalPresenter.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UIKit +import SwiftUI + +struct DuckPlayerModalPresenter { + + func presentDuckPlayerFeatureModal(on viewController: UIViewController) { + let hostingController = createHostingController() + configurePresentationStyle(for: hostingController, on: viewController) + viewController.present(hostingController, animated: true, completion: nil) + + hostingController.rootView.dismisPresentation = { + viewController.dismiss(animated: true) + } + } + + private func createHostingController() -> UIHostingController { + let duckPlayerFeaturePresentationView = DuckPlayerFeaturePresentationView() + let hostingController = UIHostingController(rootView: duckPlayerFeaturePresentationView) + hostingController.modalPresentationStyle = .pageSheet + hostingController.modalTransitionStyle = .coverVertical + return hostingController + } + + private func configurePresentationStyle(for hostingController: UIHostingController, on viewController: UIViewController) { + if #available(iOS 15.0, *) { + if let sheet = hostingController.presentationController as? UISheetPresentationController { + + if #available(iOS 16.0, *) { + let targetSize = getTargetSizeForPresentationView(on: viewController) + sheet.detents = [.custom { _ in targetSize.height }] + } else { + sheet.detents = [.large()] + + } + } + } + } + + @available(iOS 16.0, *) + private func getTargetSizeForPresentationView(on viewController: UIViewController) -> CGSize { + let duckPlayerFeaturePresentationView = DuckPlayerFeaturePresentationView() + let sizeHostingController = UIHostingController(rootView: duckPlayerFeaturePresentationView) + sizeHostingController.view.translatesAutoresizingMaskIntoConstraints = false + + viewController.view.addSubview(sizeHostingController.view) + NSLayoutConstraint.activate([ + sizeHostingController.view.widthAnchor.constraint(equalToConstant: viewController.view.frame.width) + ]) + + sizeHostingController.view.layoutIfNeeded() + + let targetSize = sizeHostingController.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + sizeHostingController.view.removeFromSuperview() + + return targetSize + } +} diff --git a/DuckDuckGo/DuckPlayer/Resources/DuckPlayer-ModalAnimation.json b/DuckDuckGo/DuckPlayer/Resources/DuckPlayer-ModalAnimation.json new file mode 100644 index 0000000000..a4ab771677 --- /dev/null +++ b/DuckDuckGo/DuckPlayer/Resources/DuckPlayer-ModalAnimation.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE 3.5.7","a":"","k":"","d":"","tc":""},"fr":29.9700012207031,"ip":7.00000028511585,"op":368.000014988947,"w":320,"h":180,"nm":"Comp 1","ddd":0,"assets":[{"id":"image_0","w":20,"h":20,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAABRklEQVQ4jbXVMWuDQBQH8P/TcwilBFKSpWRJHZ0zmTF7+jW6Cv0QBdd8jWbvGKfMjsYldEmoICVk8MzrUC1yJ9Yq/Y/Pdz/Ow3cSlDDzEsAjgJn6TEkM4JWI3qpFqkAPALwWUB3sE9H+ByywFwA3f8TKnAE8E9HeKApeDwzFWg8ARHFm2msmKQ+CMLcPp+skkxAAYAnI6dg4uo4ZjYZ0UZbMmHlJzLxWwfcPvt1ss3kJqbEE5Gph7e7v6FN5FBsqlqQ8aMIAIJMQm202T1IeqLs01OYgzO0mrIoGYW6rdQ08nK6T37CmXg1ss7umXg3sGw20BGTbxXW9GjgdG8e2YF2vBrqOGbXZpSUgXceM6sC4WhgN6bJaWLsmtPywa6YlpmL0PHVRh9EDAL+8bbTx65CYiJ7KM/TxfQV1zbkw/umCrabvL+AL4d2TwJBKfRgAAAAASUVORK5CYII=","e":1},{"id":"image_1","w":42,"h":9,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACoAAAAJCAYAAABE+77DAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAAbUlEQVQ4je3ToQ3CUBRA0fO+ZYDimYIBmIiEWViCBN8tOgC6A/Trh8I1FEHyEJwJrrn8fVdAZg4YiltWRcQEkZknnIt73nngEpl5w666ZsPY/H4k7Bt6dcUH5oZrdcWGjvvr+gOOtT2rOsaIWJ6fBxfBNPVVKAAAAABJRU5ErkJggg==","e":1},{"id":"image_2","w":67,"h":17,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEMAAAARCAYAAACGjBGPAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAACkUlEQVRYheWXz0tUURTHP+f1hplnjtaUkyGpyVhCNBCBoeCyRatsU7uCVlKLFpK08R+QoEUgErQtWtWuRYtsYxHhYANJ+COkFJxq0BydkRnnuph5M883o74RfaJ9YODec+65833nvXvuvYINpVQYuAGEgaN2/yEgBowAr0Vk3uoQs6GUqgZ6gQ53te0by8CQiLwzDQKFRAwALSUhC8OBQtvXmMTXktxzme7yRkSGAPS8oRd7ImYHm4i9DJFN6hvstV1zNPePowcybih1gW6l1FcRGZF8jRjY4P7R30b8bdOm4d4zS7Q9/3yIEhITkds6uWJZZGE4sGUiAFZ/+pl6dJHzzyI2jw/w7qrMMv8OpKyG5KrSIxNrDatp8WwX7PWo9KXWI7OGV6wvMqiU6tTJ7RpF5l9snQiTxGiQ1LRhqSG1+Z8b/AaSkEvEq/eZ9oWE8jsNjkxmQ3eveT7YEtKhYd8+V8YDOCURtQpwLGYXOG42IhNrDZUkAiCdQf/0LdtsM9drJSPtBXMrVr7XWHqlc+0dBY1OloZTSh/AE3C+dda0xy09N4tpQWPwmPzbyQQ11VLynBq5E1kR/5WYfVBZNCNDddgq5A+Q3YmwCskAi2bnwlkt1tYoM5VM0HJam7vcqs3azGOilOoBugum1LTB+J3ObZdL3c0pGvsmy3h8lQjbAalyxviiMv4mssZ2wVU+Ld1wQpbKuO6LUuoUMIi1kCZG/Uw8aN80IbVdc4SeRB1JPxhEReSheRy/Su4UWiQ1bfDraYilL8FCUqrOxam7NcPJ686W0sFgGbgnIvPWi9rG5fJ/sAz0icgUWG6tAEqpTqAHCO6DMLeJAo+t13gpNyq/bMJAvUvC3GQM+Gh+DVbWAVNnztan4+NpAAAAAElFTkSuQmCC","e":1},{"id":"image_3","w":141,"h":49,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_4","w":29,"h":4,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB0AAAAECAYAAABySjRcAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAAMElEQVQYlb3NQREAIAwEsYABrFQKUpBeB4eL7sy+I1TokKlXaByDbbxJEBaEi5pCP6MOPNpI69kPAAAAAElFTkSuQmCC","e":1},{"id":"image_5","w":101,"h":4,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGUAAAAECAYAAACX1PEwAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAAQUlEQVQ4jWO8e/fuf4ZRMCgAIyPjQiUlpQTG0UgZdGAi00C7YBSggI9MTEwLWBgYGBoH2iWjAAKYmJg2KCoqXgAApAUNMclgLKQAAAAASUVORK5CYII=","e":1},{"id":"image_6","w":141,"h":87,"u":"","p":"data:image/png;base64,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","e":1},{"id":"image_7","w":141,"h":18,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAI0AAAASCAYAAABmbl0zAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAABd0lEQVRoge3Zv2rCUBQG8C/RJYJUcAoKLQTrJqWpD+FQcO0TdMvkkzjqSxQKOlq3Ll1utuwODtdFMbjkdupiE//de5pIz29MyJcD+cjlJla3231IkuQNwC0YO0ApNU+SpF9yXfcTXBh2Asuy7mzbdss4UBh/vT47+Kta1ZmLFV+/nHVmFEV4vKA0kePgtd3GulTSmowV1o2ddvRZyosKAwD3cYyX5VJrKlZsqW8ad7fTCn3abDDaO9ZqtbQy90kpsVqtjGay02QuT6ZUKhUEQYBGo2E8ezqdYjKZGM9lh6UuTyb1ej2SwvxkN5tNkmyWjbw01A+10+mQ5rPfyEtDTUqZ9wj/DnlpZrMZWXYcxwjDkCyfpSMvjRAC4/EYi8XCaG4YhhgOh9hut0Zz2XEku6f9D3tCCAghKG7FcpD6ptH9FfBer2tdz4otszQDz0PkOGeFRY6Dgefho1YzMhwrJsv3fZX3EOy6XP2Wm/29slJqnvcQ7Lp8A7ghXrH1RxAYAAAAAElFTkSuQmCC","e":1},{"id":"image_8","w":405,"h":708,"u":"","p":"data:image/jpeg;base64,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","e":1}],"layers":[{"ddd":0,"ind":1,"ty":2,"nm":"Channel.pdf","cl":"pdf","refId":"image_0","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":64,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":349,"s":[0]},{"t":360.000014663101,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":47,"s":[220.75,47.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":64,"s":[262.25,47.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":349,"s":[262.25,47.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":360.000014663101,"s":[220.75,47.75,0]}],"ix":2},"a":{"a":0,"k":[10,10,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":2,"nm":"Text Ad.pdf","cl":"pdf","refId":"image_1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":64,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":349,"s":[0]},{"t":360.000014663101,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":47,"s":[120.75,43.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":64,"s":[59.25,43.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":349,"s":[59.25,43.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":360.000014663101,"s":[120.75,43.75,0]}],"ix":2},"a":{"a":0,"k":[21,4.5,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":2,"nm":"Ad.pdf","cl":"pdf","refId":"image_2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":64,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":349,"s":[0]},{"t":360.000014663101,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":47,"s":[130.5,95.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":64,"s":[48,95.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":349,"s":[48,95.875,0],"to":[0,0,0],"ti":[0,0,0]},{"t":360.000014663101,"s":[130.5,95.875,0]}],"ix":2},"a":{"a":0,"k":[33.5,8.5,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":2,"nm":"Description.pdf","cl":"pdf","refId":"image_3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":64,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":349,"s":[0]},{"t":360.000014663101,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":47,"s":[161.417,143,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":64,"s":[162.917,204,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":349,"s":[162.917,204,0],"to":[0,0,0],"ti":[0,0,0]},{"t":360.000014663101,"s":[161.417,143,0]}],"ix":2},"a":{"a":0,"k":[70.5,24.5,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":2,"nm":"Line 9.pdf","cl":"pdf","parent":7,"refId":"image_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.667,"y":1},"o":{"x":0.333,"y":0},"t":64,"s":[20.208,79.125,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":76,"s":[20.208,80.125,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":92,"s":[20.208,80.125,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":341,"s":[20.208,80.125,0],"to":[0,0,0],"ti":[0,0,0]},{"t":355.000014459446,"s":[20.208,79.125,0]}],"ix":2},"a":{"a":0,"k":[14.5,2,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ef":[{"ty":20,"nm":"Tint","np":6,"mn":"ADBE Tint","ix":1,"en":1,"ef":[{"ty":2,"nm":"Map Black To","mn":"ADBE Tint-0001","ix":1,"v":{"a":0,"k":[1,0.800000071526,0.200000017881,1],"ix":1}},{"ty":2,"nm":"Map White To","mn":"ADBE Tint-0002","ix":2,"v":{"a":0,"k":[0.93412989378,0.700597524643,0,1],"ix":2}},{"ty":0,"nm":"Amount to Tint","mn":"ADBE Tint-0003","ix":3,"v":{"a":1,"k":[{"i":{"x":[0.142],"y":[1]},"o":{"x":[0.951],"y":[0.71]},"t":47,"s":[100]},{"i":{"x":[0.142],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":59,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":341,"s":[0]},{"t":355.000014459446,"s":[100]}],"ix":3}},{"ty":6,"nm":"","mn":"ADBE Tint-0004","ix":4,"v":0}]}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":2,"nm":"Line 10.pdf","cl":"pdf","parent":7,"refId":"image_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.667,"y":1},"o":{"x":0.333,"y":0},"t":64,"s":[85.708,79.125,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":76,"s":[85.708,80.125,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":92,"s":[85.708,80.125,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":341,"s":[85.708,80.125,0],"to":[0,0,0],"ti":[0,0,0]},{"t":355.000014459446,"s":[85.708,79.125,0]}],"ix":2},"a":{"a":0,"k":[50.5,2,0],"ix":1},"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":64,"s":[101.485,100,100]},{"t":92.0000037472368,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":2,"nm":"Duck PLayer.pdf","cl":"pdf","refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":47,"s":[162.917,75.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":64,"s":[162.917,91.25,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":92,"s":[162.917,91.25,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":341,"s":[162.917,91.25,0],"to":[0,0,0],"ti":[0,0,0]},{"t":355.000014459446,"s":[162.917,75.75,0]}],"ix":2},"a":{"a":0,"k":[70.5,43.5,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0,0,0.667],"y":[0.978,0.978,1]},"o":{"x":[0.055,0.055,0.333],"y":[0.409,0.409,0]},"t":99,"s":[100,100,100]},{"i":{"x":[0.348,0.348,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":123,"s":[167.816,167.816,100]},{"i":{"x":[0.858,0.858,0.667],"y":[1.003,1.003,1]},"o":{"x":[0.527,0.527,0.167],"y":[0.386,0.386,0]},"t":341,"s":[167.816,167.816,100]},{"t":355.000014459446,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0,0],[0,87],[141,87],[141,0]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":2,"nm":"Top Bar.pdf","cl":"pdf","refId":"image_7","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":64,"s":[3]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":346,"s":[3]},{"t":360.000014663101,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":47,"s":[163.042,23.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":64,"s":[162.917,-14.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":346,"s":[162.917,-14.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":360.000014663101,"s":[163.042,23.75,0]}],"ix":2},"a":{"a":0,"k":[70.5,9,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":1,"nm":"Black Solid 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1.016]},"o":{"x":[0.077],"y":[0.68]},"t":99,"s":[9]},{"i":{"x":[0],"y":[8.191]},"o":{"x":[0.167],"y":[0]},"t":123,"s":[59]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":341,"s":[59]},{"t":355.000014459446,"s":[9]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[160.5,88,0],"ix":2},"a":{"a":0,"k":[160,90,0],"ix":1},"s":{"a":0,"k":[105,108.889,100],"ix":6}},"ao":0,"sw":320,"sh":180,"sc":"#000000","ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":2,"nm":"Rectangle.png","cl":"png","refId":"image_8","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1.038]},"o":{"x":[0.027],"y":[1.042]},"t":99,"s":[0]},{"i":{"x":[0],"y":[35.719]},"o":{"x":[0.167],"y":[0]},"t":123,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":341,"s":[100]},{"t":355.000014459446,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[132.5,-168.5,0],"ix":2},"a":{"a":0,"k":[202.5,354,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":55.0000022401959,"op":955.000038897947,"st":55.0000022401959,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/DuckDuckGo/DuckPlayer/DuckPlayer.xcassets/Contents.json b/DuckDuckGo/DuckPlayer/Resources/DuckPlayer.xcassets/Contents.json similarity index 100% rename from DuckDuckGo/DuckPlayer/DuckPlayer.xcassets/Contents.json rename to DuckDuckGo/DuckPlayer/Resources/DuckPlayer.xcassets/Contents.json diff --git a/DuckDuckGo/DuckPlayer/DuckPlayer.xcassets/SettingsDuckPlayerHero.imageset/Contents.json b/DuckDuckGo/DuckPlayer/Resources/DuckPlayer.xcassets/SettingsDuckPlayerHero.imageset/Contents.json similarity index 100% rename from DuckDuckGo/DuckPlayer/DuckPlayer.xcassets/SettingsDuckPlayerHero.imageset/Contents.json rename to DuckDuckGo/DuckPlayer/Resources/DuckPlayer.xcassets/SettingsDuckPlayerHero.imageset/Contents.json diff --git a/DuckDuckGo/DuckPlayer/DuckPlayer.xcassets/SettingsDuckPlayerHero.imageset/SettingsDuckPlayerHero.pdf b/DuckDuckGo/DuckPlayer/Resources/DuckPlayer.xcassets/SettingsDuckPlayerHero.imageset/SettingsDuckPlayerHero.pdf similarity index 100% rename from DuckDuckGo/DuckPlayer/DuckPlayer.xcassets/SettingsDuckPlayerHero.imageset/SettingsDuckPlayerHero.pdf rename to DuckDuckGo/DuckPlayer/Resources/DuckPlayer.xcassets/SettingsDuckPlayerHero.imageset/SettingsDuckPlayerHero.pdf diff --git a/DuckDuckGo/DuckPlayer/YouTubePlayerNavigationHandler.swift b/DuckDuckGo/DuckPlayer/YouTubePlayerNavigationHandler.swift index 520067b19a..50938264d4 100644 --- a/DuckDuckGo/DuckPlayer/YouTubePlayerNavigationHandler.swift +++ b/DuckDuckGo/DuckPlayer/YouTubePlayerNavigationHandler.swift @@ -21,7 +21,14 @@ import Foundation import ContentScopeScripts import WebKit -struct YoutubePlayerNavigationHandler { +final class YoutubePlayerNavigationHandler { + + var duckPlayerMode: DuckPlayerMode + + init(duckPlayerMode: DuckPlayerMode = AppDependencyProvider.shared.appSettings.duckPlayerMode) { + self.duckPlayerMode = duckPlayerMode + registerForNotificationChanges() + } private static let templateDirectory = "pages/duckplayer" private static let templateName = "index" @@ -69,14 +76,40 @@ struct YoutubePlayerNavigationHandler { let duckPlayerRequest = Self.makeDuckPlayerRequest(from: request) performNavigation(duckPlayerRequest, responseHTML: html, webView: webView) } + + private func registerForNotificationChanges() { + NotificationCenter.default.addObserver(self, + selector: #selector(updatePlayerMode), + name: AppUserDefaults.Notifications.duckPlayerModeChanged, + object: nil) + } + + + @objc private func updatePlayerMode(_ notification: Notification) { + if let mode = notification.object as? DuckPlayerMode { + self.duckPlayerMode = mode + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + } extension YoutubePlayerNavigationHandler: DuckNavigationHandling { - + + // Handle rendering the simulated request if the URL is duck:// + // and DuckPlayer is either enabled or alwaysAsk + @MainActor func handleNavigation(_ navigationAction: WKNavigationAction, webView: WKWebView, completion: @escaping (WKNavigationActionPolicy) -> Void) { - if let url = navigationAction.request.url, url.isDuckPlayer { + + // If DuckPlayer is Enabled or in ask mode, render the video + if let url = navigationAction.request.url, + url.isDuckURLScheme, + duckPlayerMode == .enabled || duckPlayerMode == .alwaysAsk { let html = Self.makeHTMLFromTemplate() let newRequest = Self.makeDuckPlayerRequest(from: URLRequest(url: url)) if #available(iOS 15.0, *) { @@ -85,10 +118,22 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { return } } - completion(.cancel) + + // DuckPlayer is disabled, so we redirect to the video in YouTube + if let url = navigationAction.request.url, let (videoID, timestamp) = url.youtubeVideoParams, duckPlayerMode == .disabled { + webView.load(URLRequest(url: URL.youtube(videoID, timestamp: timestamp))) + completion(.allow) + return + } + + completion(.allow) + } - func handleRedirect(url: URL?, webView: WKWebView) { + // Handle URL changes not triggered via Omnibar + // such as changes triggered via JS + @MainActor + func handleURLChange(url: URL?, webView: WKWebView) { if let url = url, url.isYoutubeVideo, !url.isDuckPlayer, let (videoID, timestamp) = url.youtubeVideoParams { webView.stopLoading() let newURL = URL.duckPlayer(videoID, timestamp: timestamp) @@ -96,9 +141,12 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { } } - func handleRedirect(_ navigationAction: WKNavigationAction, - completion: @escaping (WKNavigationActionPolicy) -> Void, - webView: WKWebView) { + // DecidePolicyFor handler to redirect relevant requests + // to duck://player + @MainActor + func handleDecidePolicyFor(_ navigationAction: WKNavigationAction, + completion: @escaping (WKNavigationActionPolicy) -> Void, + webView: WKWebView) { if let url = navigationAction.request.url, url.isYoutubeVideo, !url.isDuckPlayer, let (videoID, timestamp) = url.youtubeVideoParams { webView.load(URLRequest(url: .duckPlayer(videoID, timestamp: timestamp))) completion(.allow) @@ -107,13 +155,27 @@ extension YoutubePlayerNavigationHandler: DuckNavigationHandling { completion(.cancel) } - func goBack(webView: WKWebView) { + // Handle Webview BackButton on DuckPlayer videos + @MainActor + func handleGoBack(webView: WKWebView) { guard let backURL = webView.backForwardList.backItem?.url, backURL.isYoutubeVideo, - backURL.youtubeVideoParams?.videoID == webView.url?.youtubeVideoParams?.videoID else { + backURL.youtubeVideoParams?.videoID == webView.url?.youtubeVideoParams?.videoID, + duckPlayerMode == .enabled else { webView.goBack() return } webView.goBack(skippingHistoryItems: 2) } + + + // Handle Reload for DuckPlayer Videos + @MainActor + func handleReload(webView: WKWebView) { + if let url = webView.url, url.isDuckPlayer, !url.isDuckURLScheme, let (videoID, timestamp) = url.youtubeVideoParams { + webView.load(URLRequest(url: .duckPlayer(videoID, timestamp: timestamp))) + } else { + webView.reload() + } + } } diff --git a/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift b/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift index ee09c20949..5e02a00298 100644 --- a/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift +++ b/DuckDuckGo/DuckPlayer/YoutubeOverlayUserScript.swift @@ -21,10 +21,12 @@ import Foundation import WebKit import Common import UserScript +import Combine final class YoutubeOverlayUserScript: NSObject, Subfeature { private var duckPlayer: DuckPlayer + private var userValuesCancellable = Set() struct Constants { static let featureName = "duckPlayer" @@ -32,6 +34,18 @@ final class YoutubeOverlayUserScript: NSObject, Subfeature { init(duckPlayer: DuckPlayer) { self.duckPlayer = duckPlayer + super.init() + subscribeToDuckPlayerMode() + } + + // Listen to DuckPlayer Settings changed + private func subscribeToDuckPlayerMode() { + duckPlayer.$userValues + .dropFirst() + .sink { [weak self] updatedValues in + self?.userValuesUpdated(userValues: updatedValues) + } + .store(in: &userValuesCancellable) } enum MessageOrigin { @@ -96,10 +110,9 @@ final class YoutubeOverlayUserScript: NSObject, Subfeature { } public func userValuesUpdated(userValues: UserValues) { - guard let webView = webView else { - return assertionFailure("Could not access webView") + if let webView { + broker?.push(method: "onUserValuesChanged", params: userValues, for: self, into: webView) } - broker?.push(method: "onUserValuesChanged", params: userValues, for: self, into: webView) } // MARK: - Private Methods @@ -123,6 +136,10 @@ final class YoutubeOverlayUserScript: NSObject, Subfeature { struct UserValuesNotification: Encodable { let userValuesNotification: UserValues } + + deinit { + userValuesCancellable.removeAll() + } } extension YoutubeOverlayUserScript { @@ -131,8 +148,8 @@ extension YoutubeOverlayUserScript { guard let body = message.messageBody as? [String: Any], let parameters = body["params"] as? [String: Any] else { return nil } - let pixelName = parameters["pixelName"] as? String - + // let pixelName = parameters["pixelName"] as? String + // To be implemented at a later point return nil } diff --git a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift index b451c46e07..fcbea69a99 100644 --- a/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift +++ b/DuckDuckGo/DuckPlayer/YoutubePlayerUserScript.swift @@ -20,6 +20,7 @@ import WebKit import Common import UserScript +import Combine final class YoutubePlayerUserScript: NSObject, Subfeature { @@ -35,8 +36,22 @@ final class YoutubePlayerUserScript: NSObject, Subfeature { static let initialSetup = "initialSetup" } + private var userValuesCancellable = Set() + init(duckPlayer: DuckPlayer) { self.duckPlayer = duckPlayer + super.init() + subscribeToDuckPlayerMode() + } + + // Listen to DuckPlayer Settings changed + private func subscribeToDuckPlayerMode() { + duckPlayer.$userValues + .dropFirst() + .sink { [weak self] updatedValues in + self?.userValuesUpdated(userValues: updatedValues) + } + .store(in: &userValuesCancellable) } weak var broker: UserScriptMessageBroker? @@ -66,9 +81,13 @@ final class YoutubePlayerUserScript: NSObject, Subfeature { } } - func userValuesUpdated(userValues: UserValues) { - if let webView = webView { + public func userValuesUpdated(userValues: UserValues) { + if let webView { broker?.push(method: "onUserValuesChanged", params: userValues, for: self, into: webView) } } + + deinit { + userValuesCancellable.removeAll() + } } diff --git a/DuckDuckGo/FavoriteEmptyStateItem.swift b/DuckDuckGo/FavoriteEmptyStateItem.swift new file mode 100644 index 0000000000..e5b69afd8f --- /dev/null +++ b/DuckDuckGo/FavoriteEmptyStateItem.swift @@ -0,0 +1,33 @@ +// +// FavoriteEmptyStateItem.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 + +struct FavoriteEmptyStateItem: View { + var body: some View { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(designSystemColor: .lines), + style: StrokeStyle(lineWidth: 1.5, dash: [4, 2])) + .padding(1) // So the stroke is not clipped on the edges + } +} + +#Preview { + FavoriteEmptyStateItem() +} diff --git a/DuckDuckGo/FavoriteItemView.swift b/DuckDuckGo/FavoriteItemView.swift new file mode 100644 index 0000000000..0780d761f4 --- /dev/null +++ b/DuckDuckGo/FavoriteItemView.swift @@ -0,0 +1,61 @@ +// +// FavoriteItemView.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 DesignResourcesKit +import SwiftUI + +struct FavoriteItemView: View { + let favicon: Image? + let name: String + + var body: some View { + VStack(spacing: 6) { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(Color(designSystemColor: .surface)) + .shadow(color: .shade(0.12), radius: 0.5, y: 1) + .aspectRatio(1, contentMode: .fit) + + FavoriteIconView(favicon: favicon) + + } + + Text(name) + .daxCaption() + .foregroundColor(Color(designSystemColor: .textPrimary)) + .frame(alignment: .center) + } + } +} + +private struct FavoriteIconView: View { + let favicon: Image? + + var body: some View { + if let favicon { + favicon + .resizable() + .aspectRatio(1.0, contentMode: .fit) + } + } +} + +#Preview { + FavoriteItemView(favicon: nil, name: "Text").frame(width: 64, height: 64) +} diff --git a/DuckDuckGo/FavoritesEmptyStateView.swift b/DuckDuckGo/FavoritesEmptyStateView.swift new file mode 100644 index 0000000000..90af87372c --- /dev/null +++ b/DuckDuckGo/FavoritesEmptyStateView.swift @@ -0,0 +1,63 @@ +// +// FavoritesEmptyStateView.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 + +struct FavoritesEmptyStateView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + @State var headerPadding: CGFloat = 10 + + var body: some View { + VStack(spacing: 16) { + FavoritesSectionHeader() + .padding(.horizontal, headerPadding) + + NewTabPageGridView { placeholdersCount in + let placeholders = Array(0.. CGFloat) { + value = nextValue() + } + static var defaultValue: CGFloat = .zero +} diff --git a/DuckDuckGo/FavoritesModel.swift b/DuckDuckGo/FavoritesModel.swift new file mode 100644 index 0000000000..b1e8a494ca --- /dev/null +++ b/DuckDuckGo/FavoritesModel.swift @@ -0,0 +1,44 @@ +// +// FavoritesModel.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 + +struct Favorite: Identifiable, Equatable { + let id: Int +} + +final class FavoritesModel: ObservableObject { + + @Published private(set) var allFavorites: [Favorite] + var isEmpty: Bool { + allFavorites.isEmpty + } + + init() { + self.allFavorites = [] + } + + func toggleFavoritesPresence() { + if isEmpty { + allFavorites = (1...50).map { Favorite(id: $0) } + } else { + allFavorites = [] + } + } +} diff --git a/DuckDuckGo/FavoritesSectionHeader.swift b/DuckDuckGo/FavoritesSectionHeader.swift new file mode 100644 index 0000000000..e190400aec --- /dev/null +++ b/DuckDuckGo/FavoritesSectionHeader.swift @@ -0,0 +1,42 @@ +// +// FavoritesSectionHeader.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DesignResourcesKit + +struct FavoritesSectionHeader: View { + var body: some View { + HStack(spacing: 16, content: { + Text("Favorites") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color(designSystemColor: .textPrimary)) + .frame(alignment: .leading) + + Spacer() + + Button(action: {}, label: { + Image(.info12) + }).tintIfAvailable(Color(designSystemColor: .textPrimary)) + }) + } +} + +#Preview { + FavoritesSectionHeader() +} diff --git a/DuckDuckGo/FavoritesView.swift b/DuckDuckGo/FavoritesView.swift new file mode 100644 index 0000000000..cdb66ae363 --- /dev/null +++ b/DuckDuckGo/FavoritesView.swift @@ -0,0 +1,58 @@ +// +// FavoritesView.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 Common +import DesignResourcesKit +import DuckUI +import SwiftUI + +struct FavoritesView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @ObservedObject var model: FavoritesModel + + @State var isCollapsed: Bool = true + + var body: some View { + VStack(alignment: .center) { + + let collapsedMaxItemsCount = NewTabPageGrid.columnsCount(for: horizontalSizeClass) * 2 + + let data = isCollapsed ? Array(model.allFavorites.prefix(collapsedMaxItemsCount)) : model.allFavorites + + NewTabPageGridView { _ in + ForEach(data) { item in + FavoriteItemView(favicon: nil, name: "\(item.id)") + .frame(width: NewTabPageGrid.Item.edgeSize) + } + } + + if model.allFavorites.count > collapsedMaxItemsCount { + Button(action: { + isCollapsed.toggle() + }, label: { + ToggleExpandButtonView(isIndicatingExpand: isCollapsed).padding() + }) + } + } + } +} + +#Preview { + FavoritesView(model: FavoritesModel()) +} diff --git a/DuckDuckGo/Feedback/VPNFeedbackFormView.swift b/DuckDuckGo/Feedback/VPNFeedbackFormView.swift index fd116f15e6..b74eca8b17 100644 --- a/DuckDuckGo/Feedback/VPNFeedbackFormView.swift +++ b/DuckDuckGo/Feedback/VPNFeedbackFormView.swift @@ -25,8 +25,7 @@ import NetworkProtection @available(iOS 15.0, *) struct VPNFeedbackFormCategoryView: View { @Environment(\.dismiss) private var dismiss - let collector = DefaultVPNMetadataCollector(statusObserver: AppDependencyProvider.shared.connectionObserver, - tokenStore: AppDependencyProvider.shared.networkProtectionKeychainTokenStore) + let collector = DefaultVPNMetadataCollector(statusObserver: AppDependencyProvider.shared.connectionObserver) var body: some View { VStack { diff --git a/DuckDuckGo/Feedback/VPNMetadataCollector.swift b/DuckDuckGo/Feedback/VPNMetadataCollector.swift index b439d4c0f9..8345ec44b5 100644 --- a/DuckDuckGo/Feedback/VPNMetadataCollector.swift +++ b/DuckDuckGo/Feedback/VPNMetadataCollector.swift @@ -65,8 +65,8 @@ struct VPNMetadata: Encodable { } struct PrivacyProInfo: Encodable { - let hasToken: Bool - let subscriptionActive: Bool + let hasPrivacyProAccount: Bool + let hasVPNEntitlement: Bool } struct LastDisconnectError: Encodable { @@ -114,18 +114,18 @@ protocol VPNMetadataCollector { final class DefaultVPNMetadataCollector: VPNMetadataCollector { private let statusObserver: ConnectionStatusObserver private let serverInfoObserver: ConnectionServerInfoObserver - private let tokenStore: NetworkProtectionTokenStore + private let accountManager: AccountManager private let settings: VPNSettings private let defaults: UserDefaults init(statusObserver: ConnectionStatusObserver, serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession(), - tokenStore: NetworkProtectionTokenStore, + accountManager: AccountManager = AppDependencyProvider.shared.subscriptionManager.accountManager, settings: VPNSettings = .init(defaults: .networkProtectionGroupDefaults), defaults: UserDefaults = .networkProtectionGroupDefaults) { self.statusObserver = statusObserver self.serverInfoObserver = serverInfoObserver - self.tokenStore = tokenStore + self.accountManager = accountManager self.settings = settings self.defaults = defaults } @@ -136,7 +136,7 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { let networkInfoMetadata = await collectNetworkInformation() let vpnState = await collectVPNState() let vpnSettingsState = collectVPNSettingsState() - let privacyProInfo = collectPrivacyProInfo() + let privacyProInfo = await collectPrivacyProInfo() return VPNMetadata( appInfo: appInfoMetadata, @@ -253,20 +253,14 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { ) } - func collectPrivacyProInfo() -> VPNMetadata.PrivacyProInfo { - var hasToken: Bool { - guard let token = try? tokenStore.fetchToken(), - !token.hasPrefix(NetworkProtectionKeychainTokenStore.authTokenPrefix) else { - return false - } - return true - } - + func collectPrivacyProInfo() async -> VPNMetadata.PrivacyProInfo { + let hasVPNEntitlement = (try? await accountManager.hasEntitlement(forProductName: .networkProtection).get()) ?? false return .init( - hasToken: hasToken, - subscriptionActive: AppDependencyProvider.shared.subscriptionManager.accountManager.isUserAuthenticated + hasPrivacyProAccount: accountManager.isUserAuthenticated, + hasVPNEntitlement: hasVPNEntitlement ) } + } private extension NSError { diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index e04428bd65..5b2a294cdc 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -748,7 +748,7 @@ class MainViewController: UIViewController { } if homeTabManager.isNewTabPageSectionsEnabled { - let controller = NewTabPageViewController(rootView: NewTabPageView()) + let controller = NewTabPageViewController(rootView: NewTabPageView(favoritesModel: FavoritesModel())) newTabPageViewController = controller addToContentContainer(controller: controller) viewCoordinator.logoContainer.isHidden = true @@ -1503,7 +1503,7 @@ class MainViewController: UIViewController { } await networkProtectionTunnelController.stop() - await networkProtectionTunnelController.removeVPN() + await networkProtectionTunnelController.removeVPN(reason: .entitlementCheck) } } @@ -1511,7 +1511,7 @@ class MainViewController: UIViewController { private func onNetworkProtectionAccountSignOut(_ notification: Notification) { Task { await networkProtectionTunnelController.stop() - await networkProtectionTunnelController.removeVPN() + await networkProtectionTunnelController.removeVPN(reason: .signedOut) } } #endif diff --git a/DuckDuckGo/NetworkProtectionDebugViewController.swift b/DuckDuckGo/NetworkProtectionDebugViewController.swift index 27cdd8d7ac..c37fa4c53a 100644 --- a/DuckDuckGo/NetworkProtectionDebugViewController.swift +++ b/DuckDuckGo/NetworkProtectionDebugViewController.swift @@ -654,8 +654,7 @@ shouldShowVPNShortcut: \(vpnVisibility.shouldShowVPNShortcut() ? "YES" : "NO") @MainActor private func refreshMetadata() async { - let collector = DefaultVPNMetadataCollector(statusObserver: AppDependencyProvider.shared.connectionObserver, - tokenStore: AppDependencyProvider.shared.networkProtectionKeychainTokenStore) + let collector = DefaultVPNMetadataCollector(statusObserver: AppDependencyProvider.shared.connectionObserver) self.vpnMetadata = await collector.collectMetadata() self.tableView.reloadData() } @@ -701,7 +700,7 @@ shouldShowVPNShortcut: \(vpnVisibility.shouldShowVPNShortcut() ? "YES" : "NO") private func deleteVPNConfiguration() { Task { await AppDependencyProvider.shared.networkProtectionTunnelController.stop() - await AppDependencyProvider.shared.networkProtectionTunnelController.removeVPN() + await AppDependencyProvider.shared.networkProtectionTunnelController.removeVPN(reason: .debugMenu) } } } diff --git a/DuckDuckGo/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtectionTunnelController.swift index 80ee7defa2..02f5953de7 100644 --- a/DuckDuckGo/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtectionTunnelController.swift @@ -26,6 +26,13 @@ import NetworkExtension import NetworkProtection import Subscription +enum VPNConfigurationRemovalReason: String { + case didBecomeActiveCheck + case entitlementCheck + case signedOut + case debugMenu +} + final class NetworkProtectionTunnelController: TunnelController { static var shouldSimulateFailure: Bool = false @@ -114,8 +121,18 @@ final class NetworkProtectionTunnelController: TunnelController { tunnelManager.connection.stopVPNTunnel() } - func removeVPN() async { - try? await tunnelManager?.removeFromPreferences() + func removeVPN(reason: VPNConfigurationRemovalReason) async { + do { + try await tunnelManager?.removeFromPreferences() + + DailyPixel.fireDailyAndCount(pixel: .networkProtectionVPNConfigurationRemoved, withAdditionalParameters: [ + PixelParameters.reason: reason.rawValue + ]) + } catch { + DailyPixel.fireDailyAndCount(pixel: .networkProtectionVPNConfigurationRemovalFailed, error: error, withAdditionalParameters: [ + PixelParameters.reason: reason.rawValue + ]) + } } // MARK: - Connection Status Querying diff --git a/DuckDuckGo/NewTabPageCustomizeButtonView.swift b/DuckDuckGo/NewTabPageCustomizeButtonView.swift new file mode 100644 index 0000000000..2d74483a27 --- /dev/null +++ b/DuckDuckGo/NewTabPageCustomizeButtonView.swift @@ -0,0 +1,34 @@ +// +// NewTabPageCustomizeButtonView.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 DuckUI + +struct NewTabPageCustomizeButtonView: View { + var body: some View { + HStack { + Image(.options16) + Text("Customize") + } + } +} + +#Preview { + NewTabPageCustomizeButtonView() +} diff --git a/DuckDuckGo/NewTabPageGridView.swift b/DuckDuckGo/NewTabPageGridView.swift new file mode 100644 index 0000000000..6a01cf2393 --- /dev/null +++ b/DuckDuckGo/NewTabPageGridView.swift @@ -0,0 +1,56 @@ +// +// NewTabPageGridView.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 + +struct NewTabPageGridView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + @ViewBuilder var content: (_ columnsCount: Int) -> Content + + var body: some View { + let columnsCount = NewTabPageGrid.columnsCount(for: horizontalSizeClass) + + LazyVGrid(columns: flexibleColumns(columnsCount), content: { + content(columnsCount) + }) + .padding(0) + .offset(.zero) + .clipped() + } + + private func flexibleColumns(_ count: Int) -> [GridItem] { + Array(repeating: GridItem(.flexible(minimum: NewTabPageGrid.Item.edgeSize)), count: count) + } +} + +enum NewTabPageGrid { + enum ColumnCount { + static let compact = 4 + static let regular = 6 + } + + enum Item { + static let edgeSize = 64.0 + } + + static func columnsCount(for sizeClass: UserInterfaceSizeClass?) -> Int { + sizeClass == .regular ? ColumnCount.regular : ColumnCount.compact + } +} diff --git a/DuckDuckGo/NewTabPageView.swift b/DuckDuckGo/NewTabPageView.swift index 719eb8bbd2..ebe2d7155c 100644 --- a/DuckDuckGo/NewTabPageView.swift +++ b/DuckDuckGo/NewTabPageView.swift @@ -18,13 +18,42 @@ // import SwiftUI +import DuckUI struct NewTabPageView: View { + @ObservedObject var favoritesModel: FavoritesModel + var body: some View { - Text("Empty") + ScrollView { + VStack { + if favoritesModel.isEmpty { + FavoritesEmptyStateView() + .padding(Constant.sectionPadding) + } else { + FavoritesView(model: favoritesModel) + .padding(Constant.sectionPadding) + } + + ShortcutsView() + .padding(Constant.sectionPadding) + + Button(action: { + // Temporary action for testing purposes + favoritesModel.toggleFavoritesPresence() + }, label: { + NewTabPageCustomizeButtonView() + }).buttonStyle(SecondaryFillButtonStyle(compact: true, fullWidth: false)) + .padding(EdgeInsets(top: 88, leading: 0, bottom: 16, trailing: 0)) + } + } + .background(Color(designSystemColor: .background)) + } + + private struct Constant { + static let sectionPadding = EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24) } } #Preview { - NewTabPageView() + NewTabPageView(favoritesModel: FavoritesModel()) } diff --git a/DuckDuckGo/OmniBar.swift b/DuckDuckGo/OmniBar.swift index 7932bbf32a..0117db51df 100644 --- a/DuckDuckGo/OmniBar.swift +++ b/DuckDuckGo/OmniBar.swift @@ -25,10 +25,14 @@ import DesignResourcesKit extension OmniBar: NibLoading {} +public enum OmniBarIcon: String { + case duckPlayer = "DuckPlayerIcon" +} + // swiftlint:disable file_length // swiftlint:disable type_body_length class OmniBar: UIView { - + public static let didLayoutNotification = Notification.Name("com.duckduckgo.app.OmniBarDidLayout") @IBOutlet weak var searchLoupe: UIView! @@ -69,6 +73,9 @@ class OmniBar: UIView { private var privacyIconAndTrackersAnimator = PrivacyIconAndTrackersAnimator() private var notificationAnimator = OmniBarNotificationAnimator() + + // Set up a view to add a custom icon to the Omnibar + private var customIconView: UIImageView = UIImageView(frame: CGRect(x: 4, y: 8, width: 26, height: 26)) static func loadFromXib() -> OmniBar { return OmniBar.load(nibName: "OmniBar") @@ -263,12 +270,25 @@ class OmniBar: UIView { !privacyIconAndTrackersAnimator.isAnimatingForDaxDialog else { return } + if privacyInfo.url.isDuckPlayer { + showCustomIcon(icon: .duckPlayer) + return + } + + customIconView.isHidden = true privacyInfoContainer.privacyIcon.isHidden = false - let icon = PrivacyIconLogic.privacyIcon(for: privacyInfo) privacyInfoContainer.privacyIcon.updateIcon(icon) } + // Support static custom icons, for things like internal pages, for example + func showCustomIcon(icon: OmniBarIcon) { + privacyInfoContainer.privacyIcon.isHidden = true + customIconView.image = UIImage(named: icon.rawValue) + privacyInfoContainer.addSubview(customIconView) + customIconView.isHidden = false + } + public func startTrackersAnimation(_ privacyInfo: PrivacyInfo, forDaxDialog: Bool) { guard state.allowsTrackersAnimation, !privacyInfoContainer.isAnimationPlaying else { return } diff --git a/DuckDuckGo/Settings.bundle/Root.plist b/DuckDuckGo/Settings.bundle/Root.plist index 866e1853fe..64d4cdd2c2 100644 --- a/DuckDuckGo/Settings.bundle/Root.plist +++ b/DuckDuckGo/Settings.bundle/Root.plist @@ -6,7 +6,7 @@ DefaultValue - 7.126.0 + 7.127.0 Key version Title diff --git a/DuckDuckGo/SettingsDuckPlayerView.swift b/DuckDuckGo/SettingsDuckPlayerView.swift index 978d86cee7..3280da7f7e 100644 --- a/DuckDuckGo/SettingsDuckPlayerView.swift +++ b/DuckDuckGo/SettingsDuckPlayerView.swift @@ -31,7 +31,7 @@ struct SettingsDuckPlayerView: View { Image("SettingsDuckPlayerHero") .padding(.top, -20) // Adjust for the image padding - Text(UserText.settingsDuckPlayerTitle) + Text(UserText.duckPlayerFeatureName) .daxTitle3() Text(UserText.settingsDuckPlayerInfoText) @@ -57,7 +57,7 @@ struct SettingsDuckPlayerView: View { .multilineTextAlignment(.center) } } - .applySettingsListModifiers(title: UserText.settingsDuckPlayerTitle, + .applySettingsListModifiers(title: UserText.duckPlayerFeatureName, displayMode: .inline, viewModel: viewModel) } diff --git a/DuckDuckGo/SettingsMainSettingsView.swift b/DuckDuckGo/SettingsMainSettingsView.swift index ef185dc038..7268ddc014 100644 --- a/DuckDuckGo/SettingsMainSettingsView.swift +++ b/DuckDuckGo/SettingsMainSettingsView.swift @@ -71,8 +71,8 @@ struct SettingsMainSettingsView: View { // Duck Player if viewModel.isInternalUser { NavigationLink(destination: SettingsDuckPlayerView().environmentObject(viewModel)) { - SettingsCellView(label: "Duck Player", - image: Image("SettingsDuckPlayer")) + SettingsCellView(label: UserText.duckPlayerFeatureName, + image: Image("DuckPlayerIcon")) } } } diff --git a/DuckDuckGo/SettingsRootView.swift b/DuckDuckGo/SettingsRootView.swift index cc3e346a67..32d4599313 100644 --- a/DuckDuckGo/SettingsRootView.swift +++ b/DuckDuckGo/SettingsRootView.swift @@ -117,9 +117,6 @@ struct SettingsRootView: View { SubscriptionContainerViewFactory.makeSubscribeFlow(origin: origin, navigationCoordinator: subscriptionNavigationCoordinator, subscriptionManager: AppDependencyProvider.shared.subscriptionManager) - case .subscriptionRestoreFlow: - SubscriptionContainerViewFactory.makeRestoreFlow(navigationCoordinator: subscriptionNavigationCoordinator, - subscriptionManager: AppDependencyProvider.shared.subscriptionManager) default: EmptyView() } diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index 228140d87b..5bedc53e6f 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -251,7 +251,9 @@ final class SettingsViewModel: ObservableObject { var duckPlayerModeBinding: Binding { Binding( - get: { self.state.duckPlayerMode ?? .alwaysAsk }, + get: { + return self.state.duckPlayerMode ?? .alwaysAsk + }, set: { self.appSettings.duckPlayerMode = $0 self.state.duckPlayerMode = $0 @@ -609,7 +611,6 @@ extension SettingsViewModel { case dbp case itr case subscriptionFlow(origin: String? = nil) - case subscriptionRestoreFlow // Add other cases as needed var id: String { @@ -618,7 +619,6 @@ extension SettingsViewModel { case .dbp: return "dbp" case .itr: return "itr" case .subscriptionFlow: return "subscriptionFlow" - case .subscriptionRestoreFlow: return "subscriptionRestoreFlow" // Ensure all cases are covered } } diff --git a/DuckDuckGo/ShortcutItemView.swift b/DuckDuckGo/ShortcutItemView.swift new file mode 100644 index 0000000000..914ba7b202 --- /dev/null +++ b/DuckDuckGo/ShortcutItemView.swift @@ -0,0 +1,49 @@ +// +// ShortcutItemView.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 DesignResourcesKit +import SwiftUI + +struct ShortcutItemView: View { + let name: String + + var body: some View { + VStack(spacing: 6) { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(Color(designSystemColor: .surface)) + .shadow(color: .shade(0.12), radius: 0.5, y: 1) + .aspectRatio(1, contentMode: .fit) + .frame(width: NewTabPageGrid.Item.edgeSize) + Image("Login-32-Color") + .resizable() + .aspectRatio(1.0, contentMode: .fit) + .frame(width: NewTabPageGrid.Item.edgeSize * 0.5) + } + Text(name) + .daxCaption() + .foregroundColor(Color(designSystemColor: .textPrimary)) + .frame(alignment: .center) + } + } +} + +#Preview { + ShortcutItemView(name: "Shortcut") +} diff --git a/DuckDuckGo/ShortcutsView.swift b/DuckDuckGo/ShortcutsView.swift new file mode 100644 index 0000000000..cb3bb026d0 --- /dev/null +++ b/DuckDuckGo/ShortcutsView.swift @@ -0,0 +1,56 @@ +// +// ShortcutsView.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 + +enum Shortcut: Int, CaseIterable, Equatable, Identifiable { + var id: Int { rawValue } + + case bookmarks, aiChat, vpn, passwords + + var name: String { + switch self { + case .bookmarks: + UserText.homeTabShortcutBookmarks + case .aiChat: + UserText.homeTabShortcutAIChat + case .vpn: + UserText.homeTabShortcutVPN + case .passwords: + UserText.homeTabShortcutPasswords + } + } +} + +struct ShortcutsView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @State var enabledShortcuts: [Shortcut] = Array(Shortcut.allCases.prefix(upTo: 3)) + + var body: some View { + NewTabPageGridView { _ in + ForEach(enabledShortcuts) { shortcut in + ShortcutItemView(name: shortcut.name) + } + } + } +} + +#Preview { + ShortcutsView() +} diff --git a/DuckDuckGo/Subscription/SubscriptionManageriOS14.swift b/DuckDuckGo/Subscription/SubscriptionManageriOS14.swift index b909855ae6..684ea20a41 100644 --- a/DuckDuckGo/Subscription/SubscriptionManageriOS14.swift +++ b/DuckDuckGo/Subscription/SubscriptionManageriOS14.swift @@ -32,7 +32,7 @@ class SubscriptionManageriOS14: SubscriptionManager { var currentEnvironment: SubscriptionEnvironment = SubscriptionEnvironment.default var canPurchase: Bool = false func loadInitialData() {} - func updateSubscriptionStatus(completion: @escaping (Bool) -> Void) {} + func refreshCachedSubscriptionAndEntitlements(completion: @escaping (Bool) -> Void) {} func url(for type: SubscriptionURL) -> URL { URL(string: "https://duckduckgo.com")! diff --git a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift index 699720a670..e257a026be 100644 --- a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift +++ b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift @@ -95,15 +95,19 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec private let subscriptionManager: SubscriptionManager private var accountManager: AccountManager { subscriptionManager.accountManager } private let appStorePurchaseFlow: AppStorePurchaseFlow + private let appStoreRestoreFlow: AppStoreRestoreFlow + private let appStoreAccountManagementFlow: AppStoreAccountManagementFlow init(subscriptionManager: SubscriptionManager, subscriptionAttributionOrigin: String?, appStorePurchaseFlow: AppStorePurchaseFlow, - appStoreRestoreFlow: AppStoreRestoreFlow) { + appStoreRestoreFlow: AppStoreRestoreFlow, + appStoreAccountManagementFlow: AppStoreAccountManagementFlow) { self.subscriptionManager = subscriptionManager self.appStorePurchaseFlow = appStorePurchaseFlow self.appStoreRestoreFlow = appStoreRestoreFlow + self.appStoreAccountManagementFlow = appStoreAccountManagementFlow self.subscriptionAttributionOrigin = subscriptionAttributionOrigin } @@ -187,7 +191,9 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec // MARK: Broker Methods (Called from WebView via UserScripts) func getSubscription(params: Any, original: WKScriptMessage) async -> Encodable? { + await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() let authToken = accountManager.authToken ?? Constants.empty + return [Constants.token: authToken] } @@ -399,7 +405,6 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec // MARK: Native methods - Called from ViewModels func restoreAccountFromAppStorePurchase() async throws { setTransactionStatus(.restoring) -// let appStoreRestoreFlow = AppStoreRestoreFlow(subscriptionManager: subscriptionManager) let result = await appStoreRestoreFlow.restoreAccountFromPastPurchase() switch result { case .success: diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift index 0bfbc9410a..17274f5c48 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift @@ -38,15 +38,13 @@ final class SubscriptionContainerViewModel: ObservableObject { self.userScript = userScript subFeature.cleanup() self.subFeature = subFeature - let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: subscriptionManager) self.flow = SubscriptionFlowViewModel(origin: origin, userScript: userScript, subFeature: subFeature, subscriptionManager: subscriptionManager) self.restore = SubscriptionRestoreViewModel(userScript: userScript, subFeature: subFeature, - subscriptionManager: subscriptionManager, - appStoreAccountManagementFlow: appStoreAccountManagementFlow) + subscriptionManager: subscriptionManager) self.email = SubscriptionEmailViewModel(userScript: userScript, subFeature: subFeature, subscriptionManager: subscriptionManager) diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift index 8fc0ea302e..b185c9d512 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift @@ -79,6 +79,11 @@ final class SubscriptionEmailViewModel: ObservableObject { webViewModel.url?.forComparison() == subscriptionPurchaseURL.forComparison() } + private var isVerifySubscriptionPage: Bool { + let confirmSubscriptionURL = subscriptionManager.url(for: .baseURL).appendingPathComponent("confirm") + return webViewModel.url?.forComparison() == confirmSubscriptionURL.forComparison() + } + init(userScript: SubscriptionPagesUserScript, subFeature: SubscriptionPagesUseSubscriptionFeature, subscriptionManager: SubscriptionManager) { @@ -132,7 +137,7 @@ final class SubscriptionEmailViewModel: ObservableObject { let addEmailToSubscriptionURL = subscriptionManager.url(for: .addEmail) let manageSubscriptionEmailURL = subscriptionManager.url(for: .manageEmail) emailURL = accountManager.email == nil ? addEmailToSubscriptionURL : manageSubscriptionEmailURL - state.viewTitle = accountManager.email == nil ? UserText.subscriptionRestoreAddEmailTitle : UserText.subscriptionManageEmailTitle + state.viewTitle = accountManager.email == nil ? UserText.subscriptionRestoreAddEmailTitle : UserText.subscriptionEditEmailTitle // Also we assume subscription requires managing, and not activation state.managingSubscriptionEmail = true @@ -224,15 +229,13 @@ final class SubscriptionEmailViewModel: ObservableObject { private func updateBackButton(canNavigateBack: Bool) { // If the view is not Activation Success, or Welcome page, allow WebView Back Navigation - if !isWelcomePageOrSuccessPage { + if !isWelcomePageOrSuccessPage && !isVerifySubscriptionPage { self.state.canNavigateBack = canNavigateBack self.state.backButtonTitle = UserText.backButtonTitle } else { self.state.canNavigateBack = false self.state.backButtonTitle = UserText.settingsTitle } - - } // MARK: - diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift index f6fb943634..0c178c8257 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift @@ -30,7 +30,6 @@ final class SubscriptionRestoreViewModel: ObservableObject { let subFeature: SubscriptionPagesUseSubscriptionFeature let subscriptionManager: SubscriptionManager var accountManager: AccountManager { subscriptionManager.accountManager } - let appStoreAccountManagementFlow: AppStoreAccountManagementFlow private var cancellables = Set() @@ -39,7 +38,6 @@ final class SubscriptionRestoreViewModel: ObservableObject { } struct State { - var isAddingDevice: Bool = false var transactionStatus: SubscriptionTransactionStatus = .idle var activationResult: SubscriptionActivationResult = .unknown var subscriptionEmail: String? @@ -60,68 +58,28 @@ final class SubscriptionRestoreViewModel: ObservableObject { init(userScript: SubscriptionPagesUserScript, subFeature: SubscriptionPagesUseSubscriptionFeature, subscriptionManager: SubscriptionManager, - appStoreAccountManagementFlow: AppStoreAccountManagementFlow, isAddingDevice: Bool = false) { self.userScript = userScript self.subFeature = subFeature self.subscriptionManager = subscriptionManager - self.appStoreAccountManagementFlow = appStoreAccountManagementFlow - self.state.isAddingDevice = false } func onAppear() { DispatchQueue.main.async { self.resetState() } - Task { await setupContent() } } func onFirstAppear() async { - Pixel.fire(pixel: .privacyProSettingsAddDevice) await setupTransactionObserver() - await refreshToken() } private func cleanUp() { cancellables.removeAll() } - private func refreshToken() async { - if state.isAddingDevice { - await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() - } - } - - private func setupContent() async { - if state.isAddingDevice { - DispatchQueue.main.async { - self.state.isLoading = true - } - - guard let token = accountManager.accessToken else { return } - switch await accountManager.fetchAccountDetails(with: token) { - case .success(let details): - DispatchQueue.main.async { - self.state.subscriptionEmail = details.email - self.state.isLoading = false - self.state.viewTitle = UserText.subscriptionAddDeviceTitle - } - default: - DispatchQueue.main.async { - self.state.viewTitle = UserText.subscriptionActivate - self.state.isLoading = false - } - } - } - } - @MainActor private func resetState() { - state.isAddingDevice = false - if accountManager.isUserAuthenticated { - state.isAddingDevice = true - } - state.isShowingActivationFlow = false state.shouldShowPlans = false state.isShowingWelcomePage = false diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift index bb67efca93..61afacec98 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift @@ -27,21 +27,22 @@ import Core final class SubscriptionSettingsViewModel: ObservableObject { private let subscriptionManager: SubscriptionManager - private var subscriptionUpdateTimer: Timer? private var signOutObserver: Any? private var externalAllowedDomains = ["stripe.com"] struct State { var subscriptionDetails: String = "" - var subscriptionType: String = "" + var subscriptionEmail: String? var isShowingRemovalNotice: Bool = false var shouldDismissView: Bool = false var isShowingGoogleView: Bool = false var isShowingFAQView: Bool = false + var isShowingLearnMoreView: Bool = false var subscriptionInfo: Subscription? var isLoadingSubscriptionInfo: Bool = false - + var isLoadingEmailInfo: Bool = false + // Used to display stripe WebUI var stripeViewModel: SubscriptionExternalLinkViewModel? var isShowingStripeView: Bool = false @@ -51,9 +52,11 @@ final class SubscriptionSettingsViewModel: ObservableObject { // Used to display the FAQ WebUI var faqViewModel: SubscriptionExternalLinkViewModel + var learnMoreViewModel: SubscriptionExternalLinkViewModel - init(faqURL: URL) { + init(faqURL: URL, learnMoreURL: URL) { self.faqViewModel = SubscriptionExternalLinkViewModel(url: faqURL) + self.learnMoreViewModel = SubscriptionExternalLinkViewModel(url: learnMoreURL) } } @@ -67,56 +70,111 @@ final class SubscriptionSettingsViewModel: ObservableObject { init(subscriptionManager: SubscriptionManager = AppDependencyProvider.shared.subscriptionManager) { self.subscriptionManager = subscriptionManager let subscriptionFAQURL = subscriptionManager.url(for: .faq) - self.state = State(faqURL: subscriptionFAQURL) + let learnMoreURL = subscriptionFAQURL.appendingPathComponent("adding-email") + self.state = State(faqURL: subscriptionFAQURL, learnMoreURL: learnMoreURL) - setupSubscriptionUpdater() setupNotificationObservers() } private var dateFormatter: DateFormatter = { let formatter = DateFormatter() - formatter.dateFormat = "MMM dd, yyyy" + formatter.dateFormat = "MMMM dd, yyyy" return formatter }() func onFirstAppear() { - self.fetchAndUpdateSubscriptionDetails(cachePolicy: .returnCacheDataElseLoad) - } - - private func fetchAndUpdateSubscriptionDetails(cachePolicy: APICachePolicy = .returnCacheDataElseLoad, - loadingIndicator: Bool = true) { Task { - if loadingIndicator { displayLoader(true) } - guard let token = self.subscriptionManager.accountManager.accessToken else { return } - let subscriptionResult = await self.subscriptionManager.subscriptionEndpointService.getSubscription(accessToken: token, - cachePolicy: cachePolicy) - switch subscriptionResult { - case .success(let subscription): - DispatchQueue.main.async { - self.state.subscriptionInfo = subscription - if loadingIndicator { self.displayLoader(false) } - } - await updateSubscriptionsStatusMessage(status: subscription.status, - date: subscription.expiresOrRenewsAt, - product: subscription.productId, - billingPeriod: subscription.billingPeriod) - default: - DispatchQueue.main.async { - if loadingIndicator { self.displayLoader(true) } - self.showConnectionError(true) - } - - subscriptionUpdateTimer?.invalidate() + // Load initial state from the cache + async let loadedEmailFromCache = await self.fetchAndUpdateAccountEmail(cachePolicy: .returnCacheDataDontLoad, + loadingIndicator: false) + async let loadedSubscriptionFromCache = await self.fetchAndUpdateSubscriptionDetails(cachePolicy: .returnCacheDataDontLoad, + loadingIndicator: false) + let (hasLoadedEmailFromCache, hasLoadedSubscriptionFromCache) = await (loadedEmailFromCache, loadedSubscriptionFromCache) + + // Reload remote subscription and email state + async let reloadedEmail = await self.fetchAndUpdateAccountEmail(cachePolicy: .reloadIgnoringLocalCacheData, + loadingIndicator: !hasLoadedEmailFromCache) + async let reloadedSubscription = await self.fetchAndUpdateSubscriptionDetails(cachePolicy: .reloadIgnoringLocalCacheData, + loadingIndicator: !hasLoadedSubscriptionFromCache) + let (hasReloadedEmail, hasReloadedSubscription) = await (reloadedEmail, reloadedSubscription) + + // In case any fetch fails show an error + if !hasReloadedEmail || !hasReloadedSubscription { + self.showConnectionError(true) } } } - - private func displayLoader(_ show: Bool) { + + private func fetchAndUpdateSubscriptionDetails(cachePolicy: APICachePolicy, loadingIndicator: Bool) async -> Bool { + guard let token = self.subscriptionManager.accountManager.accessToken else { return false } + + if loadingIndicator { displaySubscriptionLoader(true) } + let subscriptionResult = await self.subscriptionManager.subscriptionEndpointService.getSubscription(accessToken: token, + cachePolicy: cachePolicy) + switch subscriptionResult { + case .success(let subscription): + DispatchQueue.main.async { + self.state.subscriptionInfo = subscription + if loadingIndicator { self.displaySubscriptionLoader(false) } + } + await updateSubscriptionsStatusMessage(status: subscription.status, + date: subscription.expiresOrRenewsAt, + product: subscription.productId, + billingPeriod: subscription.billingPeriod) + return true + default: + DispatchQueue.main.async { + if loadingIndicator { self.displaySubscriptionLoader(true) } + } + return false + } + } + + func fetchAndUpdateAccountEmail(cachePolicy: APICachePolicy = .returnCacheDataElseLoad, loadingIndicator: Bool) async -> Bool { + guard let token = self.subscriptionManager.accountManager.accessToken else { return false } + + switch cachePolicy { + case .returnCacheDataDontLoad, .returnCacheDataElseLoad: + self.state.subscriptionEmail = self.subscriptionManager.accountManager.email + return true + case .reloadIgnoringLocalCacheData: + break + } + + if loadingIndicator { displayEmailLoader(true) } + switch await self.subscriptionManager.accountManager.fetchAccountDetails(with: token) { + case .success(let details): + DispatchQueue.main.async { + self.state.subscriptionEmail = details.email + if loadingIndicator { self.displayEmailLoader(false) } + } + + // If fetched email is different then update accountManager + if details.email != subscriptionManager.accountManager.email { + let externalID = subscriptionManager.accountManager.externalID + subscriptionManager.accountManager.storeAccount(token: token, email: details.email, externalID: externalID) + } + return true + default: + DispatchQueue.main.async { + if loadingIndicator { self.displayEmailLoader(true) } + } + return false + } + } + + private func displaySubscriptionLoader(_ show: Bool) { DispatchQueue.main.async { self.state.isLoadingSubscriptionInfo = show } } - + + private func displayEmailLoader(_ show: Bool) { + DispatchQueue.main.async { + self.state.isLoadingEmailInfo = show + } + } + func manageSubscription() { switch state.subscriptionInfo?.platform { case .apple: @@ -140,25 +198,18 @@ final class SubscriptionSettingsViewModel: ObservableObject { } } - // Re-fetch subscription from server ignoring cache - // This ensure that if the user re-subscribed or changed plan on the Apple view, state is updated - private func setupSubscriptionUpdater() { - subscriptionUpdateTimer = Timer.scheduledTimer(withTimeInterval: 8.0, repeats: true) { [weak self] _ in - guard let strongSelf = self else { return } - strongSelf.fetchAndUpdateSubscriptionDetails(cachePolicy: .reloadIgnoringLocalCacheData, loadingIndicator: false) - } - } - @MainActor private func updateSubscriptionsStatusMessage(status: Subscription.Status, date: Date, product: String, billingPeriod: Subscription.BillingPeriod) { + let billingPeriod = billingPeriod == .monthly ? UserText.subscriptionMonthlyBillingPeriod : UserText.subscriptionAnnualBillingPeriod let date = dateFormatter.string(from: date) - let expiredStates: [Subscription.Status] = [.expired, .inactive] - if expiredStates.contains(status) { + + switch status { + case .autoRenewable: + state.subscriptionDetails = UserText.renewingSubscriptionInfo(billingPeriod: billingPeriod, renewalDate: date) + case .expired, .inactive: state.subscriptionDetails = UserText.expiredSubscriptionInfo(expiration: date) - } else { - let statusString = (status == .autoRenewable) ? UserText.subscriptionRenews : UserText.subscriptionExpires - state.subscriptionDetails = UserText.subscriptionInfo(status: statusString, expiration: date) - state.subscriptionType = billingPeriod == .monthly ? UserText.subscriptionMonthly : UserText.subscriptionAnnual + default: + state.subscriptionDetails = UserText.expiringSubscriptionInfo(billingPeriod: billingPeriod, expiryDate: date) } } @@ -192,7 +243,13 @@ final class SubscriptionSettingsViewModel: ObservableObject { state.isShowingFAQView = value } } - + + func displayLearnMoreView(_ value: Bool) { + if value != state.isShowingLearnMoreView { + state.isShowingLearnMoreView = value + } + } + func showConnectionError(_ value: Bool) { if value != state.isShowingConnectionError { DispatchQueue.main.async { @@ -248,7 +305,6 @@ final class SubscriptionSettingsViewModel: ObservableObject { } deinit { - subscriptionUpdateTimer?.invalidate() signOutObserver = nil } } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift b/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift index 5f56f8d7d6..61b38b3292 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift @@ -24,7 +24,7 @@ import SwiftUI struct SubscriptionContainerView: View { enum CurrentView { - case subscribe, restore + case subscribe, restore, email } @Environment(\.dismiss) var dismiss @@ -54,6 +54,8 @@ struct SubscriptionContainerView: View { SubscriptionRestoreView(viewModel: restoreViewModel, emailViewModel: emailViewModel, currentView: $currentViewState).environmentObject(subscriptionNavigationCoordinator) + case .email: + SubscriptionEmailView(viewModel: emailViewModel) } } } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionContainerViewFactory.swift b/DuckDuckGo/Subscription/Views/SubscriptionContainerViewFactory.swift index d3fc8369ad..32c1423204 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionContainerViewFactory.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionContainerViewFactory.swift @@ -27,6 +27,7 @@ enum SubscriptionContainerViewFactory { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: subscriptionManager) let viewModel = SubscriptionContainerViewModel( subscriptionManager: subscriptionManager, @@ -35,7 +36,8 @@ enum SubscriptionContainerViewFactory { subFeature: SubscriptionPagesUseSubscriptionFeature(subscriptionManager: subscriptionManager, subscriptionAttributionOrigin: origin, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow) + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow) ) return SubscriptionContainerView(currentView: .subscribe, viewModel: viewModel) .environmentObject(navigationCoordinator) @@ -45,6 +47,7 @@ enum SubscriptionContainerViewFactory { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: subscriptionManager) let viewModel = SubscriptionContainerViewModel( subscriptionManager: subscriptionManager, @@ -53,10 +56,34 @@ enum SubscriptionContainerViewFactory { subFeature: SubscriptionPagesUseSubscriptionFeature(subscriptionManager: subscriptionManager, subscriptionAttributionOrigin: nil, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow) + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow) ) return SubscriptionContainerView(currentView: .restore, viewModel: viewModel) .environmentObject(navigationCoordinator) } + static func makeEmailFlow(navigationCoordinator: SubscriptionNavigationCoordinator, + subscriptionManager: SubscriptionManager, + onDisappear: @escaping () -> Void) -> some View { + let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: subscriptionManager) + let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: subscriptionManager, + appStoreRestoreFlow: appStoreRestoreFlow) + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: subscriptionManager) + + let viewModel = SubscriptionContainerViewModel( + subscriptionManager: subscriptionManager, + origin: nil, + userScript: SubscriptionPagesUserScript(), + subFeature: SubscriptionPagesUseSubscriptionFeature(subscriptionManager: subscriptionManager, + subscriptionAttributionOrigin: nil, + appStorePurchaseFlow: appStorePurchaseFlow, + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow) + ) + return SubscriptionContainerView(currentView: .email, viewModel: viewModel) + .environmentObject(navigationCoordinator) + .onDisappear(perform: { onDisappear() }) + } + } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift b/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift index 648cc3b925..3368a036b0 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift @@ -23,7 +23,6 @@ import DesignResourcesKit import Core @available(iOS 15.0, *) -// swiftlint:disable type_body_length struct SubscriptionRestoreView: View { @Environment(\.dismiss) var dismiss @@ -65,18 +64,11 @@ struct SubscriptionRestoreView: View { } var body: some View { - if viewModel.state.isAddingDevice { - ZStack { - baseView - } - } else { - ZStack { - baseView - - if viewModel.state.transactionStatus != .idle { - PurchaseInProgressView(status: getTransactionStatus()) - } - + ZStack { + baseView + + if viewModel.state.transactionStatus != .idle { + PurchaseInProgressView(status: getTransactionStatus()) } } } @@ -153,8 +145,6 @@ struct SubscriptionRestoreView: View { .onAppear { viewModel.onAppear() } - - } // MARK: - @@ -182,38 +172,15 @@ struct SubscriptionRestoreView: View { if !viewModel.state.isLoading { VStack(alignment: .leading) { - if !viewModel.state.isAddingDevice { - Text(UserText.subscriptionActivateEmailDescription) - .daxSubheadRegular() - .foregroundColor(Color(designSystemColor: .textSecondary)) - getCellButton(buttonText: UserText.subscriptionActivateEmailButton, - action: { - DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseEmailStart) - DailyPixel.fire(pixel: .privacyProWelcomeAddDevice) - viewModel.showActivationFlow(true) - }) - } else if viewModel.state.subscriptionEmail == nil { - Text(UserText.subscriptionAddDeviceEmailDescription) - .daxSubheadRegular() - .foregroundColor(Color(designSystemColor: .textSecondary)) - getCellButton(buttonText: UserText.subscriptionRestoreAddEmailButton, - action: { - Pixel.fire(pixel: .privacyProAddDeviceEnterEmail, debounce: 1) - viewModel.showActivationFlow(true) - }) - } else { - Text(viewModel.state.subscriptionEmail ?? "").daxSubheadSemibold() - Text(UserText.subscriptionManageEmailDescription) - .daxSubheadRegular() - .foregroundColor(Color(designSystemColor: .textSecondary)) - HStack { - getCellButton(buttonText: UserText.subscriptionManageEmailButton, - action: { - Pixel.fire(pixel: .privacyProSubscriptionManagementEmail, debounce: 1) - viewModel.showActivationFlow(true) - }) - } - } + Text(UserText.subscriptionActivateEmailDescription) + .daxSubheadRegular() + .foregroundColor(Color(designSystemColor: .textSecondary)) + getCellButton(buttonText: UserText.subscriptionActivateEmailButton, + action: { + DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseEmailStart) + DailyPixel.fire(pixel: .privacyProWelcomeAddDevice) + viewModel.showActivationFlow(true) + }) } } else { SwiftUI.ProgressView() @@ -258,11 +225,11 @@ struct SubscriptionRestoreView: View { private var headerView: some View { VStack(spacing: Constants.headerLineSpacing) { Image(Constants.heroImage) - Text(viewModel.state.isAddingDevice ? UserText.subscriptionAddDeviceHeaderTitle : UserText.subscriptionActivateTitle) + Text(UserText.subscriptionActivateTitle) .daxHeadline() .multilineTextAlignment(.center) .foregroundColor(Color(designSystemColor: .textPrimary)) - Text(viewModel.state.isAddingDevice ? UserText.subscriptionAddDeviceDescription : UserText.subscriptionActivateHeaderDescription) + Text(UserText.subscriptionActivateHeaderDescription) .daxFootnoteRegular() .foregroundColor(Color(designSystemColor: .textSecondary)) .multilineTextAlignment(.center) @@ -272,22 +239,20 @@ struct SubscriptionRestoreView: View { @ViewBuilder private var footerView: some View { - if !viewModel.state.isAddingDevice { - VStack(alignment: .leading, spacing: Constants.footerLineSpacing) { - Text(UserText.subscriptionActivateDescription) - .daxFootnoteRegular() - .foregroundColor(Color(designSystemColor: .textSecondary)) - Button(action: { - viewModel.restoreAppstoreTransaction() - }, label: { - Text(UserText.subscriptionRestoreAppleID) - .daxFootnoteSemibold() - .foregroundColor(Color(designSystemColor: .accent)) - }) - } + VStack(alignment: .leading, spacing: Constants.footerLineSpacing) { + Text(UserText.subscriptionActivateDescription) + .daxFootnoteRegular() + .foregroundColor(Color(designSystemColor: .textSecondary)) + Button(action: { + viewModel.restoreAppstoreTransaction() + }, label: { + Text(UserText.subscriptionRestoreAppleID) + .daxFootnoteSemibold() + .foregroundColor(Color(designSystemColor: .accent)) + }) } } - + private func getAlert() -> Alert { switch viewModel.state.activationResult { case .activated: @@ -336,4 +301,3 @@ struct SubscriptionRestoreView: View { } } -// swiftlint:enable type_body_length diff --git a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift index 679bae8284..76dcf7f7d7 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift @@ -34,10 +34,10 @@ struct SubscriptionSettingsView: View { @State var isShowingGoogleView = false @State var isShowingRemovalNotice = false @State var isShowingFAQView = false - @State var isShowingRestoreView = false + @State var isShowingLearnMoreView = false + @State var isShowingEmailView = false @State var isShowingConnectionError = false - @State var isLoading = false - + enum Constants { static let alertIcon = "Exclamation-Color-16" } @@ -54,35 +54,66 @@ struct SubscriptionSettingsView: View { private var headerSection: some View { Section { - let active = viewModel.state.subscriptionInfo?.isActive ?? false + let isExpired = !(viewModel.state.subscriptionInfo?.isActive ?? false) VStack(alignment: .center, spacing: 7) { Image("Privacy-Pro-96x96") Text(UserText.subscriptionTitle).daxTitle2() - if !viewModel.state.isLoadingSubscriptionInfo { - if active { - Text(viewModel.state.subscriptionType).daxHeadline() - } + + if isExpired { HStack { - if !active { Image(Constants.alertIcon) } + Image(Constants.alertIcon) Text(viewModel.state.subscriptionDetails) .daxSubheadRegular() .foregroundColor(Color(designSystemColor: .textSecondary)) } - } else { - SwiftUI.ProgressView() } } } .listRowBackground(Color.clear) .frame(maxWidth: .infinity, alignment: .center) - } - + + private var devicesSection: some View { + Section(header: Text(UserText.subscriptionDevicesSectionHeader), + footer: devicesSectionFooter) { + + if !viewModel.state.isLoadingEmailInfo { + NavigationLink(destination: SubscriptionContainerViewFactory.makeEmailFlow( + navigationCoordinator: subscriptionNavigationCoordinator, + subscriptionManager: AppDependencyProvider.shared.subscriptionManager, + onDisappear: { + Task { await viewModel.fetchAndUpdateAccountEmail(cachePolicy: .reloadIgnoringLocalCacheData, loadingIndicator: false) } + }), + isActive: $isShowingEmailView) { + if let email = viewModel.state.subscriptionEmail { + SettingsCellView(label: UserText.subscriptionEditEmailButton, + subtitle: email) + } else { + SettingsCellView(label: UserText.subscriptionAddEmailButton) + } + }.isDetailLink(false) + } else { + SwiftUI.ProgressView() + } + } + } + + private var devicesSectionFooter: some View { + let hasEmail = !(viewModel.state.subscriptionEmail ?? "").isEmpty + let footerText = hasEmail ? UserText.subscriptionDevicesSectionWithEmailFooter : UserText.subscriptionDevicesSectionNoEmailFooter + return Text(.init("\(footerText)")) // required to parse markdown formatting + .environment(\.openURL, OpenURLAction { _ in + viewModel.displayLearnMoreView(true) + return .handled + }) + } + private var manageSection: some View { - Section(header: Text(UserText.subscriptionManageTitle)) { + Section(header: Text(UserText.subscriptionManageTitle), + footer: manageSectionFooter) { let active = viewModel.state.subscriptionInfo?.isActive ?? false SettingsCustomCell(content: { - + if !viewModel.state.isLoadingSubscriptionInfo { if active { Text(UserText.subscriptionChangePlan) @@ -115,21 +146,6 @@ struct SubscriptionSettingsView: View { SubscriptionExternalLinkView(viewModel: stripeViewModel, title: UserText.subscriptionManagePlan) } } - } - } - - private var devicesSection: some View { - Section(header: Text(UserText.subscriptionManageDevices)) { - - NavigationLink(destination: SubscriptionContainerViewFactory.makeRestoreFlow( - navigationCoordinator: subscriptionNavigationCoordinator, - subscriptionManager: AppDependencyProvider.shared.subscriptionManager), - isActive: $isShowingRestoreView) { - SettingsCustomCell(content: { - Text(UserText.subscriptionAddDeviceButton) - .daxBodyRegular() - }) - }.isDetailLink(false) SettingsCustomCell(content: { Text(UserText.subscriptionRemoveFromDevice) @@ -137,10 +153,20 @@ struct SubscriptionSettingsView: View { .foregroundColor(Color.init(designSystemColor: .accent))}, action: { viewModel.displayRemovalNotice(true) }, isButton: true) - } } - + + private var manageSectionFooter: some View { + let isExpired = !(viewModel.state.subscriptionInfo?.isActive ?? false) + return Group { + if isExpired { + EmptyView() + } else { + Text(viewModel.state.subscriptionDetails) + } + } + } + @ViewBuilder var helpSection: some View { Section(header: Text(UserText.subscriptionHelpAndSupport), footer: Text(UserText.subscriptionFAQFooter)) { @@ -167,7 +193,6 @@ struct SubscriptionSettingsView: View { List { headerSection - manageSection devicesSection .alert(isPresented: $isShowingRemovalNotice) { Alert( @@ -182,6 +207,7 @@ struct SubscriptionSettingsView: View { } ) } + manageSection helpSection } @@ -218,14 +244,22 @@ struct SubscriptionSettingsView: View { viewModel.displayRemovalNotice(value) } - // Removal Notice + // FAQ .onChange(of: viewModel.state.isShowingFAQView) { value in isShowingFAQView = value } .onChange(of: isShowingFAQView) { value in viewModel.displayFAQView(value) } - + + // Learn More + .onChange(of: viewModel.state.isShowingLearnMoreView) { value in + isShowingLearnMoreView = value + } + .onChange(of: isShowingLearnMoreView) { value in + viewModel.displayLearnMoreView(value) + } + // Connection Error .onChange(of: viewModel.state.isShowingConnectionError) { value in isShowingConnectionError = value @@ -233,11 +267,20 @@ struct SubscriptionSettingsView: View { .onChange(of: isShowingConnectionError) { value in viewModel.showConnectionError(value) } - + + .onChange(of: isShowingEmailView) { value in + if value { + if let email = viewModel.state.subscriptionEmail, !email.isEmpty { + Pixel.fire(pixel: .privacyProSubscriptionManagementEmail, debounce: 1) + } else { + Pixel.fire(pixel: .privacyProAddDeviceEnterEmail, debounce: 1) + } + } + } .onReceive(subscriptionNavigationCoordinator.$shouldPopToSubscriptionSettings) { shouldDismiss in if shouldDismiss { - isShowingRestoreView = false + isShowingEmailView = false } } @@ -254,7 +297,11 @@ struct SubscriptionSettingsView: View { .sheet(isPresented: $isShowingFAQView, content: { SubscriptionExternalLinkView(viewModel: viewModel.state.faqViewModel, title: UserText.subscriptionFAQ) }) - + + .sheet(isPresented: $isShowingLearnMoreView, content: { + SubscriptionExternalLinkView(viewModel: viewModel.state.learnMoreViewModel, title: UserText.subscriptionFAQ) + }) + .onFirstAppear { viewModel.onFirstAppear() } diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 2c5e3fb563..f20df4c932 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -348,9 +348,8 @@ class TabViewController: UIViewController { decorate() addTextSizeObserver() subscribeToEmailProtectionSignOutNotification() - registerForDownloadsNotifications() - + if #available(iOS 16.4, *) { registerForInspectableWebViewNotifications() } @@ -359,7 +358,13 @@ class TabViewController: UIViewController { observeNetPConnectionStatusChanges() #endif } - + + private func configureDuckPlayerUserScripts() { + userScripts?.youtubeOverlayScript?.webView = webView + userScripts?.youtubePlayerUserScript?.webView = webView + } + + @available(iOS 16.4, *) private func registerForInspectableWebViewNotifications() { NotificationCenter.default.addObserver(self, @@ -660,7 +665,7 @@ class TabViewController: UIViewController { let url, url.isYoutubeVideo, appSettings.duckPlayerMode == .enabled { - handler.handleRedirect(url: url, webView: webView) + handler.handleURLChange(url: url, webView: webView) } } } @@ -713,7 +718,11 @@ class TabViewController: UIViewController { public func reload() { updateContentMode() cachedRuntimeConfigurationForDomain = [:] - webView.reload() + if let url = webView.url, url.isDuckPlayer, let handler = youtubeNavigationHandler { + handler.handleReload(webView: webView) + } else { + webView.reload() + } privacyDashboard?.dismiss(animated: true) } @@ -725,7 +734,7 @@ class TabViewController: UIViewController { dismissJSAlertIfNeeded() if let url = url, url.isDuckPlayer, let handler = youtubeNavigationHandler { - handler.goBack(webView: webView) + handler.handleGoBack(webView: webView) chromeDelegate?.omniBar.resignFirstResponder() return } @@ -1647,7 +1656,7 @@ extension TabViewController: WKNavigationDelegate { if let handler = youtubeNavigationHandler, url.isYoutubeVideo, appSettings.duckPlayerMode == .enabled { - handler.handleRedirect(navigationAction, completion: completion, webView: webView) + handler.handleDecidePolicyFor(navigationAction, completion: completion, webView: webView) return } @@ -2316,12 +2325,14 @@ extension TabViewController: UserContentControllerDelegate { userScripts.textSizeUserScript.textSizeAdjustmentInPercents = appSettings.textSize userScripts.loginFormDetectionScript?.delegate = self userScripts.autoconsentUserScript.delegate = self - + userScripts.youtubeOverlayScript?.webView = webView + userScripts.youtubePlayerUserScript?.webView = webView + performanceMetrics = PerformanceMetricsSubfeature(targetWebview: webView) userScripts.contentScopeUserScriptIsolated.registerSubfeature(delegate: performanceMetrics!) adClickAttributionLogic.onRulesChanged(latestRules: ContentBlocking.shared.contentBlockingManager.currentRules) - + let tdsKey = DefaultContentBlockerRulesListsSource.Constants.trackerDataSetRulesListName let notificationsTriggeringReload = [ PreserveLogins.Notifications.loginDetectionStateChanged, diff --git a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift index 6ae62b1a5e..c9cb80dd49 100644 --- a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift @@ -419,8 +419,8 @@ extension TabViewController { } private func onToggleProtectionAction(forDomain domain: String, isProtected: Bool) { - let config = ContentBlocking.shared.privacyConfigurationManager.privacyConfig - if isProtected && ToggleReportsFeature(privacyConfiguration: config).isEnabled { + let manager = ToggleReportsManager(feature: ToggleReportsFeature(manager: ContentBlocking.shared.privacyConfigurationManager)) + if isProtected && manager.shouldShowToggleReport { delegate?.tab(self, didRequestToggleReportWithCompletionHandler: { [weak self] didSendReport in self?.togglePrivacyProtection(domain: domain, didSendReport: didSendReport) }) diff --git a/DuckDuckGo/ToggleExpandButtonView.swift b/DuckDuckGo/ToggleExpandButtonView.swift new file mode 100644 index 0000000000..376a1e52b1 --- /dev/null +++ b/DuckDuckGo/ToggleExpandButtonView.swift @@ -0,0 +1,35 @@ +// +// ToggleExpandButtonView.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 DuckUI + +struct ToggleExpandButtonView: View { + + let isIndicatingExpand: Bool + + var body: some View { + Text(isIndicatingExpand ? "Show more" : "Show less") + .daxCaption() + } +} + +#Preview { + return ToggleExpandButtonView(isIndicatingExpand: true) +} diff --git a/DuckDuckGo/UserScripts.swift b/DuckDuckGo/UserScripts.swift index a8d6e2697a..3a5f3ae18b 100644 --- a/DuckDuckGo/UserScripts.swift +++ b/DuckDuckGo/UserScripts.swift @@ -108,5 +108,5 @@ final class UserScripts: UserScriptsProvider { return wkUserScripts } } - + } diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 17849eb004..c9f1a16264 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1030,13 +1030,20 @@ But if you *do* want a peek under the hood, you can find more information about public static let subscriptionTitle = NSLocalizedString("subscription.title", value: "Privacy Pro", comment: "Navigation bar Title for subscriptions") public static let subscriptionCloseButton = NSLocalizedString("subscription.close", value: "Close", comment: "Navigation Button for closing subscription view") - static func subscriptionInfo(status: String, expiration: String) -> String { - let localized = NSLocalizedString("subscription.subscription.active.caption", - value: "Your subscription %@ on %@", - comment: "Subscription Expiration Data. This reads as 'Your subscription (renews or expires) on (date)'") - return String(format: localized, status, expiration) + static func renewingSubscriptionInfo(billingPeriod: String, renewalDate: String) -> String { + let localized = NSLocalizedString("subscription.subscription.renewing.caption", + value: "Your %@ subscription renews on %@.", + comment: "Subscription renewal info. This reads as 'Your (monthly or annual) subscription renews on (date)'") + return String(format: localized, billingPeriod, renewalDate) } - + + static func expiringSubscriptionInfo(billingPeriod: String, expiryDate: String) -> String { + let localized = NSLocalizedString("subscription.subscription.expiring.caption", + value: "Your %@ subscription expires on %@.", + comment: "Subscription expiration info. This reads as 'Your (monthly or annual) subscription expires on (date)'") + return String(format: localized, billingPeriod, expiryDate) + } + static func expiredSubscriptionInfo(expiration: String) -> String { let localized = NSLocalizedString("subscription.subscription.expired.caption", value: "Your subscription expired on %@", @@ -1044,20 +1051,19 @@ But if you *do* want a peek under the hood, you can find more information about return String(format: localized, expiration) } - public static let subscriptionRenews = NSLocalizedString("subscription.renews", value: "renews", comment: "text for renewal string") - public static let subscriptionExpires = NSLocalizedString("subscription.expires", value: "expires", comment: "text for expiration string") - public static let subscriptionMonthly = NSLocalizedString("subscription.monthly", value: "Monthly Subscription", comment: "Subscription type") - public static let subscriptionAnnual = NSLocalizedString("subscription.annual", value: "Annual Subscription", comment: "Subscription type") - - public static let subscriptionManageDevices = NSLocalizedString("subscription.manage.devices", value: "Manage Devices", comment: "Header for the device management section") - public static let subscriptionAddDeviceButton = NSLocalizedString("subscription.add.device.button", value: "Add to Another Device", comment: "Add to another device button") + public static let subscriptionMonthlyBillingPeriod = NSLocalizedString("subscription.billing.period.monthly", value: "monthly", comment: "Subscription monthly billing period type") + public static let subscriptionAnnualBillingPeriod = NSLocalizedString("subscription.billing.period.annual", value: "annual", comment: "Subscription annual billing period type") + + public static let subscriptionDevicesSectionHeader = NSLocalizedString("subscription.devices.header", value: "Activate on Other Devices", comment: "Header for section for activating subscription on other devices") + public static let subscriptionDevicesSectionNoEmailFooter = NSLocalizedString("subscription.devices.no.email.footer", value: "Add an optional email to your subscription or use your Apple ID to access Privacy Pro on other devices. **[Learn more](https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/adding-email/)**", comment: "Footer for section for activating subscription on other devices when email was not yet added") + public static let subscriptionDevicesSectionWithEmailFooter = NSLocalizedString("subscription.devices.with.email.footer", value: "Use this email to activate your subscription in Settings > Privacy Pro in the DuckDuckGo app on your other devices. **[Learn more](https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/adding-email/)**", comment: "Footer for section for activating subscription on other devices when email is added") public static let subscriptionRemoveFromDevice = NSLocalizedString("subscription.remove.from.device.button", value: "Remove From This Device", comment: "Remove from this device button") public static let subscriptionManageTitle = NSLocalizedString("subscription.manage.title", value: "Subscription", comment: "Header for the subscription section") public static let subscriptionManagePlan = NSLocalizedString("subscription.manage.plan", value: "Manage Plan", comment: "Manage Plan header") - public static let subscriptionChangePlan = NSLocalizedString("subscription.change.plan", value: "Change Plan or Billing", comment: "Change plan or billing title") + public static let subscriptionChangePlan = NSLocalizedString("subscription.change.plan", value: "Update Plan or Cancel", comment: "Change plan or cancel title") public static let subscriptionHelpAndSupport = NSLocalizedString("subscription.help", value: "Help and support", comment: "Help and support Section header") - public static let subscriptionFAQ = NSLocalizedString("subscription.faq", value: "Privacy Pro FAQ", comment: "FAQ Button") - public static let subscriptionFAQFooter = NSLocalizedString("subscription.faq.description", value: "Get answers to frequently asked questions about Privacy Pro in our help pages.", comment: "FAQ Description") + public static let subscriptionFAQ = NSLocalizedString("subscription.faq", value: "FAQs and Support", comment: "FAQ Button") + public static let subscriptionFAQFooter = NSLocalizedString("subscription.faq.description", value: "Get answers to frequently asked questions or contact Privacy Pro support from our help pages.", comment: "FAQ Description") // Remove subscription confirmation public static let subscriptionRemoveFromDeviceConfirmTitle = NSLocalizedString("subscription.remove.from.device.title", value: "Remove from this device?", comment: "Remove from device confirmation dialog title") @@ -1067,7 +1073,6 @@ But if you *do* want a peek under the hood, you can find more information about public static let subscriptionRemovalConfirmation = NSLocalizedString("subscription.cancel.message", value: "Your subscription has been removed from this device.", comment: "Subscription Removal confirmation message") // Subscription Restore - public static let subscriptionActivate = NSLocalizedString("subscription.activate", value: "Activate Subscription", comment: "Subscription Activation Window Title") public static let subscriptionActivateTitle = NSLocalizedString("subscription.activate.title", value: "Activate your subscription on this device", comment: "Subscription Activation Title") public static let subscriptionActivateDescription = NSLocalizedString("subscription.activate.description", value: "Your subscription is automatically available in DuckDuckGo on any device signed in to your Apple ID.", comment: "Subscription Activation Info") public static let subscriptionActivateHeaderDescription = NSLocalizedString("subscription.activate..header.description", value: "Access your Privacy Pro subscription on this device via Apple ID or an email address.", comment: "Subscription Activation Info") @@ -1078,14 +1083,11 @@ But if you *do* want a peek under the hood, you can find more information about public static let subscriptionActivateEmail = NSLocalizedString("subscription.activate.email", value: "Email", comment: "Email option for activation") public static let subscriptionActivateEmailTitle = NSLocalizedString("subscription.activate.email.title", value: "Activate Subscription", comment: "Activate subscription title") public static let subscriptionActivateEmailDescription = NSLocalizedString("subscription.activate.email.description", value: "Use your email to activate your subscription on this device.", comment: "Description for Email activation") - public static let subscriptionAddDeviceEmailDescription = NSLocalizedString("subscription.addDevice.email.description", value: "Add an email address to access your subscription in DuckDuckGo on other devices. We’ll only use this address to verify your subscription.", comment: "Description for Email adding") - public static let subscriptionAddEmailButton = NSLocalizedString("subscription.activate.add.email.button", value: "Add Email", comment: "Restore button title for Email") + public static let subscriptionAddEmailButton = NSLocalizedString("subscription.activate.add.email.button", value: "Add Email", comment: "Button for adding email address to subscription") + public static let subscriptionEditEmailButton = NSLocalizedString("subscription.activate.edit.email.button", value: "Edit Email", comment: "Button for editing email address added to subscription") public static let subscriptionActivateEmailButton = NSLocalizedString("subscription.activate.email.button", value: "Enter Email", comment: "Restore button title for Email") // Add to other devices (AppleID / Email) - public static let subscriptionAddDeviceTitle = NSLocalizedString("subscription.add.device.title", value: "Add Device", comment: "Add to another device view title") - public static let subscriptionAddDeviceHeaderTitle = NSLocalizedString("subscription.add.device.header.title", value: "Use your subscription on other devices", comment: "Add subscription to other device title ") - public static let subscriptionAddDeviceDescription = NSLocalizedString("subscription.add.device.description", value: "Access your Privacy Pro subscription via an email address.", comment: "Subscription Add device Info") public static let subscriptionAvailableInApple = NSLocalizedString("subscription.available.apple", value: "Privacy Pro is available on any device signed in to the same Apple ID.", comment: "Subscription availability message on Apple devices") public static let subscriptionManageEmailResendInstructions = NSLocalizedString("subscription.add.device.resend.instructions", value: "Resend Instructions", comment: "Resend activation instructions button") @@ -1094,13 +1096,10 @@ But if you *do* want a peek under the hood, you can find more information about // Add Email To subscription public static let subscriptionAddEmail = NSLocalizedString("subscription.add.email", value: "Add an email address to activate your subscription on your other devices. We’ll only use this address to verify your subscription.", comment: "Add email to an existing subscription") - public static let subscriptionRestoreAddEmailButton = NSLocalizedString("subscription.add.email.button", value: "Add Email", comment: "Button title for adding email to subscription") public static let subscriptionRestoreAddEmailTitle = NSLocalizedString("subscription.add.email.title", value: "Add Email", comment: "View title for adding email to subscription") // Manage Subscription Email - public static let subscriptionManageEmailDescription = NSLocalizedString("subscription.manage.email.description", value: "Use this email to activate your subscription from browser settings in the DuckDuckGo app on other devices..", comment: "Description for Email Management options") - public static let subscriptionManageEmailButton = NSLocalizedString("subscription.activate.manage.email.button", value: "Manage", comment: "Restore button title for Managing Email") - public static let subscriptionManageEmailTitle = NSLocalizedString("subscription.activate.manage.email.title", value: "Manage Email", comment: "View Title for managing your email account") + public static let subscriptionEditEmailTitle = NSLocalizedString("subscription.activate.edit.email.title", value: "Edit Email", comment: "View Title for editing your email account") public static let subscriptionManageEmailCancelButton = NSLocalizedString("subscription.activate.manage.email.cancel", value: "Cancel", comment: "Button title for cancelling email deletion") public static let subscriptionManageEmailOKButton = NSLocalizedString("subscription.activate.manage.email.OK", value: "OK", comment: "Button title for confirming email deletion") @@ -1163,14 +1162,24 @@ But if you *do* want a peek under the hood, you can find more information about // Duck Player public static let duckPlayerAlwaysEnabledLabel = NSLocalizedString("duckPlayer.alwaysEnabled.label", value: "Always", comment: "Text displayed when DuckPlayer is always enabled") + 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 settingsDuckPlayerTitle = NSLocalizedString("duckplayer.settings.title", value: "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 settingsOpenVideosInDuckPlayerTitle = NSLocalizedString("duckplayer.settings.title", value: "Duck Player", comment: "Settings screen cell text for DuckPlayer settings") public static let settingsDuckPlayerFooter = NSLocalizedString("duckplayer.settings.footer", value: "DuckDuckGo provides all the privacy essentials you need to protect yourself as you browse the web.", comment: "Footer label in the settings screen for Duck Player") public static let settingsDuckPlayerLearnMore = NSLocalizedString("duckplayer.settings.learn-more", value: "Learn More", comment: "Button that takes the user to learn more about Duck Player.") public static let settingsDuckPlayerInfoText = NSLocalizedString("duckplayer.settings.info-text", value: "Duck Player provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations.", comment: "Text explaining what Duck Player is in the settings screen.") + public static let duckPlayerPresentationModalTitle = NSLocalizedString("duckplayer.presentation.modal.title", value: "Drowning in ads on YouTube? Try Duck Player!", comment: "Two line title (separated by \n) for the feature explanation") + public static let duckPlayerPresentationModalBody = NSLocalizedString("duckplayer.presentation.modal.body", value: "Duck Player lets you watch YouTube without targeted ads in a theater-like experience in DuckDuckGo and what you watch won’t influence your recommendations.", comment: "Body text for the modal feature explanation") + public static let duckPlayerPresentationModalDismissButton = NSLocalizedString("duckplayer.presentation.modal.dismiss-button", value: "Got it!", comment: "Button that will dismiss the modal") + + // Home Tab Shortcuts + public static let homeTabShortcutBookmarks = NSLocalizedString("home.tab.shortcut.bookmarks", value: "Bookmarks", comment: "Shortcut title leading to Bookmarks") + public static let homeTabShortcutAIChat = NSLocalizedString("home.tab.shortcut.ai.chat", value: "AI Chat", comment: "Shortcut title leading to AI Chat") + public static let homeTabShortcutVPN = NSLocalizedString("home.tab.shortcut.vpn", value: "VPN", comment: "Shortcut title leading to VPN") + public static let homeTabShortcutPasswords = NSLocalizedString("home.tab.shortcut.passwords", value: "Passwords", comment: "Shortcut title leading to Passwords") } diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 26c4f07dc6..dde3b956c3 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -877,6 +877,15 @@ /* Text displayed when DuckPlayer is in off. */ "duckPlayer.never.label" = "Never"; +/* Body text for the modal feature explanation */ +"duckplayer.presentation.modal.body" = "Duck Player lets you watch YouTube without targeted ads in a theater-like experience in DuckDuckGo and what you watch won’t influence your recommendations."; + +/* Button that will dismiss the modal */ +"duckplayer.presentation.modal.dismiss-button" = "Got it!"; + +/* Two line title (separated by \n) for the feature explanation */ +"duckplayer.presentation.modal.title" = "Drowning in ads on YouTube? Try Duck Player!"; + /* Footer label in the settings screen for Duck Player */ "duckplayer.settings.footer" = "DuckDuckGo provides all the privacy essentials you need to protect yourself as you browse the web."; @@ -1234,6 +1243,18 @@ /* Home is this context is the bottom home row (dock) */ "home.row.reminder.title" = "Take DuckDuckGo home"; +/* Shortcut title leading to AI Chat */ +"home.tab.shortcut.ai.chat" = "AI Chat"; + +/* Shortcut title leading to Bookmarks */ +"home.tab.shortcut.bookmarks" = "Bookmarks"; + +/* Shortcut title leading to Passwords */ +"home.tab.shortcut.passwords" = "Passwords"; + +/* Shortcut title leading to VPN */ +"home.tab.shortcut.vpn" = "VPN"; + /* This describes empty tab */ "homeTab.searchAndFavorites" = "Search or enter address"; @@ -1981,13 +2002,10 @@ But if you *do* want a peek under the hood, you can find more information about /* No comment provided by engineer. */ "siteFeedback.urlPlaceholder" = "Which website is broken?"; -/* Subscription Activation Window Title */ -"subscription.activate" = "Activate Subscription"; - /* Subscription Activation Info */ "subscription.activate..header.description" = "Access your Privacy Pro subscription on this device via Apple ID or an email address."; -/* Restore button title for Email */ +/* Button for adding email address to subscription */ "subscription.activate.add.email.button" = "Add Email"; /* Apple ID option for activation */ @@ -2002,6 +2020,12 @@ But if you *do* want a peek under the hood, you can find more information about /* Subscription Activation Info */ "subscription.activate.description" = "Your subscription is automatically available in DuckDuckGo on any device signed in to your Apple ID."; +/* Button for editing email address added to subscription */ +"subscription.activate.edit.email.button" = "Edit Email"; + +/* View Title for editing your email account */ +"subscription.activate.edit.email.title" = "Edit Email"; + /* Email option for activation */ "subscription.activate.email" = "Email"; @@ -2014,57 +2038,30 @@ But if you *do* want a peek under the hood, you can find more information about /* Activate subscription title */ "subscription.activate.email.title" = "Activate Subscription"; -/* Restore button title for Managing Email */ -"subscription.activate.manage.email.button" = "Manage"; - /* Button title for cancelling email deletion */ "subscription.activate.manage.email.cancel" = "Cancel"; /* Button title for confirming email deletion */ "subscription.activate.manage.email.OK" = "OK"; -/* View Title for managing your email account */ -"subscription.activate.manage.email.title" = "Manage Email"; - /* Restore button title for AppleID */ "subscription.activate.restore.apple" = "Restore Purchase"; /* Subscription Activation Title */ "subscription.activate.title" = "Activate your subscription on this device"; -/* Add to another device button */ -"subscription.add.device.button" = "Add to Another Device"; - -/* Subscription Add device Info */ -"subscription.add.device.description" = "Access your Privacy Pro subscription via an email address."; - -/* Add subscription to other device title */ -"subscription.add.device.header.title" = "Use your subscription on other devices"; - /* Resend activation instructions button */ "subscription.add.device.resend.instructions" = "Resend Instructions"; -/* Add to another device view title */ -"subscription.add.device.title" = "Add Device"; - /* Add email to an existing subscription */ "subscription.add.email" = "Add an email address to activate your subscription on your other devices. We’ll only use this address to verify your subscription."; -/* Button title for adding email to subscription */ -"subscription.add.email.button" = "Add Email"; - /* View title for adding email to subscription */ "subscription.add.email.title" = "Add Email"; -/* Description for Email adding */ -"subscription.addDevice.email.description" = "Add an email address to access your subscription in DuckDuckGo on other devices. We’ll only use this address to verify your subscription."; - /* Title for Alert messages */ "subscription.alert.title" = "subscription.alert.title"; -/* Subscription type */ -"subscription.annual" = "Annual Subscription"; - /* Subscription availability message on Apple devices */ "subscription.available.apple" = "Privacy Pro is available on any device signed in to the same Apple ID."; @@ -2074,11 +2071,17 @@ But if you *do* want a peek under the hood, you can find more information about /* Title for the manage billing page */ "subscription.billing.google.title" = "Subscription Plans"; +/* Subscription annual billing period type */ +"subscription.billing.period.annual" = "annual"; + +/* Subscription monthly billing period type */ +"subscription.billing.period.monthly" = "monthly"; + /* Subscription Removal confirmation message */ "subscription.cancel.message" = "Your subscription has been removed from this device."; -/* Change plan or billing title */ -"subscription.change.plan" = "Change Plan or Billing"; +/* Change plan or cancel title */ +"subscription.change.plan" = "Update Plan or Cancel"; /* Navigation Button for closing subscription view */ "subscription.close" = "Close"; @@ -2086,6 +2089,15 @@ But if you *do* want a peek under the hood, you can find more information about /* Title for Confirm messages */ "subscription.confirm.title" = "Are you sure?"; +/* Header for section for activating subscription on other devices */ +"subscription.devices.header" = "Activate on Other Devices"; + +/* Footer for section for activating subscription on other devices when email was not yet added */ +"subscription.devices.no.email.footer" = "Add an optional email to your subscription or use your Apple ID to access Privacy Pro on other devices. **[Learn more](https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/adding-email/)**"; + +/* Footer for section for activating subscription on other devices when email is added */ +"subscription.devices.with.email.footer" = "Use this email to activate your subscription in Settings > Privacy Pro in the DuckDuckGo app on your other devices. **[Learn more](https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/adding-email/)**"; + /* Alert content for not found subscription */ "subscription.email.inactive.alert.message" = "The subscription associated with this email is no longer active."; @@ -2095,14 +2107,11 @@ But if you *do* want a peek under the hood, you can find more information about /* Alert content for not found subscription */ "subscription.expired.alert.message" = "The subscription associated with this Apple ID is no longer active."; -/* text for expiration string */ -"subscription.expires" = "expires"; - /* FAQ Button */ -"subscription.faq" = "Privacy Pro FAQ"; +"subscription.faq" = "FAQs and Support"; /* FAQ Description */ -"subscription.faq.description" = "Get answers to frequently asked questions about Privacy Pro in our help pages."; +"subscription.faq.description" = "Get answers to frequently asked questions or contact Privacy Pro support from our help pages."; /* Cancel action for the existing subscription dialog */ "subscription.found.cancel" = "Cancel"; @@ -2119,21 +2128,12 @@ But if you *do* want a peek under the hood, you can find more information about /* Help and support Section header */ "subscription.help" = "Help and support"; -/* Header for the device management section */ -"subscription.manage.devices" = "Manage Devices"; - -/* Description for Email Management options */ -"subscription.manage.email.description" = "Use this email to activate your subscription from browser settings in the DuckDuckGo app on other devices.."; - /* Manage Plan header */ "subscription.manage.plan" = "Manage Plan"; /* Header for the subscription section */ "subscription.manage.title" = "Subscription"; -/* Subscription type */ -"subscription.monthly" = "Monthly Subscription"; - /* Alert content for not found subscription */ "subscription.notFound.alert.message" = "There is no subscription associated with this Apple ID."; @@ -2185,9 +2185,6 @@ But if you *do* want a peek under the hood, you can find more information about /* Remove subscription cancel button text */ "subscription.remove.subscription.cancel" = "Cancel"; -/* text for renewal string */ -"subscription.renews" = "renews"; - /* Button text for general error message */ "subscription.restore.backend.error.button" = "Back to Settings"; @@ -2212,12 +2209,15 @@ But if you *do* want a peek under the hood, you can find more information about /* Alert title for restored purchase */ "subscription.restore.success.alert.title" = "You’re all set."; -/* Subscription Expiration Data. This reads as 'Your subscription (renews or expires) on (date)' */ -"subscription.subscription.active.caption" = "Your subscription %1$@ on %2$@"; - /* Subscription Expired Data. This reads as 'Your subscription expired on (date)' */ "subscription.subscription.expired.caption" = "Your subscription expired on %@"; +/* Subscription expiration info. This reads as 'Your (monthly or annual) subscription expires on (date)' */ +"subscription.subscription.expiring.caption" = "Your %1$@ subscription expires on %2$@."; + +/* Subscription renewal info. This reads as 'Your (monthly or annual) subscription renews on (date)' */ +"subscription.subscription.renewing.caption" = "Your %1$@ subscription renews on %2$@."; + /* Navigation bar Title for subscriptions */ "subscription.title" = "Privacy Pro"; diff --git a/DuckDuckGoTests/BookmarkStateRepairTests.swift b/DuckDuckGoTests/BookmarkStateRepairTests.swift new file mode 100644 index 0000000000..421f4a3f7a --- /dev/null +++ b/DuckDuckGoTests/BookmarkStateRepairTests.swift @@ -0,0 +1,118 @@ +// +// BookmarkStateRepairTests.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 CoreData +import Bookmarks +import TestUtils +@testable import Core +@testable import DuckDuckGo + +class BookmarkStateRepairTests: XCTestCase { + + let dbStack = MockBookmarksDatabase.make(prepareFolderStructure: false) + + let mockKeyValueStore = MockKeyValueStore() + + override func setUp() async throws { + try await super.setUp() + + let containerLocation = MockBookmarksDatabase.tempDBDir() + try FileManager.default.createDirectory(at: containerLocation, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + try await super.tearDown() + + try dbStack.tearDown(deleteStores: true) + } + + func testWhenThereIsNoIssueThenThereAreNoChanges() { + prepareValidStructure() + + let testContext = dbStack.makeContext(concurrencyType: .privateQueueConcurrencyType) + testContext.performAndWait { + + let repair = BookmarksStateRepair(keyValueStore: mockKeyValueStore) + + XCTAssertEqual(repair.validateAndRepairPendingDeletionState(in: testContext), .noBrokenData) + XCTAssertEqual(repair.validateAndRepairPendingDeletionState(in: testContext), .alreadyPerformed) + } + } + + func testWhenPendingDeletionIsNilThenItIsFixed() { + prepareBrokenStructure() + + let testContext = dbStack.makeContext(concurrencyType: .privateQueueConcurrencyType) + testContext.performAndWait { + + let repair = BookmarksStateRepair(keyValueStore: mockKeyValueStore) + + XCTAssertEqual(repair.validateAndRepairPendingDeletionState(in: testContext), .dataRepaired) + + mockKeyValueStore.removeObject(forKey: BookmarksStateRepair.Constants.pendingDeletionRepaired) + XCTAssertEqual(repair.validateAndRepairPendingDeletionState(in: testContext), .noBrokenData) + XCTAssertEqual(repair.validateAndRepairPendingDeletionState(in: testContext), .alreadyPerformed) + } + } + + private func prepareStructure(_ block: (NSManagedObjectContext) -> Void) { + let context = dbStack.makeContext(concurrencyType: .privateQueueConcurrencyType) + + context.performAndWait { + BookmarkUtils.prepareFoldersStructure(in: context) + block(context) + do { + try context.save() + } catch { + XCTFail("Could not save context") + } + } + } + + private func prepareValidStructure() { + prepareStructure { context in + guard let root = BookmarkUtils.fetchRootFolder(context) else { + XCTFail("Root missing") + return + } + + let bookmarkA = BookmarkEntity.makeBookmark(title: "A", url: "A", parent: root, context: context) + let bookmarkB = BookmarkEntity.makeBookmark(title: "B", url: "B", parent: root, context: context) + let bookmarkC = BookmarkEntity.makeBookmark(title: "C", url: "C", parent: root, context: context) + } + } + + private func prepareBrokenStructure() { + prepareStructure { context in + guard let root = BookmarkUtils.fetchRootFolder(context) else { + XCTFail("Root missing") + return + } + + let bookmarkA = BookmarkEntity.makeBookmark(title: "A", url: "A", parent: root, context: context) + let bookmarkB = BookmarkEntity.makeBookmark(title: "B", url: "B", parent: root, context: context) + let bookmarkC = BookmarkEntity.makeBookmark(title: "C", url: "C", parent: root, context: context) + + bookmarkA.setValue(nil, forKey: #keyPath(BookmarkEntity.isPendingDeletion)) + bookmarkB.setValue(nil, forKey: #keyPath(BookmarkEntity.isPendingDeletion)) + } + } + +} diff --git a/DuckDuckGoTests/SubscriptionContainerViewModelTests.swift b/DuckDuckGoTests/SubscriptionContainerViewModelTests.swift index 747a509329..922c406ba6 100644 --- a/DuckDuckGoTests/SubscriptionContainerViewModelTests.swift +++ b/DuckDuckGoTests/SubscriptionContainerViewModelTests.swift @@ -35,6 +35,7 @@ final class SubscriptionContainerViewModelTests: XCTestCase { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: mockDependencyProvider.subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) // WHEN sut = .init(subscriptionManager: mockDependencyProvider.subscriptionManager, @@ -43,7 +44,8 @@ final class SubscriptionContainerViewModelTests: XCTestCase { subFeature: .init(subscriptionManager: mockDependencyProvider.subscriptionManager, subscriptionAttributionOrigin: nil, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow)) + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow)) // THEN XCTAssertEqual(sut.flow.purchaseURL, expectedURL) @@ -53,6 +55,8 @@ final class SubscriptionContainerViewModelTests: XCTestCase { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: mockDependencyProvider.subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) + // WHEN sut = .init(subscriptionManager: mockDependencyProvider.subscriptionManager, origin: nil, @@ -60,7 +64,8 @@ final class SubscriptionContainerViewModelTests: XCTestCase { subFeature: .init(subscriptionManager: mockDependencyProvider.subscriptionManager, subscriptionAttributionOrigin: nil, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow)) + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow)) // THEN XCTAssertEqual(sut.flow.purchaseURL, SubscriptionURL.purchase.subscriptionURL(environment: .production)) diff --git a/DuckDuckGoTests/SubscriptionFlowViewModelTests.swift b/DuckDuckGoTests/SubscriptionFlowViewModelTests.swift index 48a0a3ba34..6d375a2a87 100644 --- a/DuckDuckGoTests/SubscriptionFlowViewModelTests.swift +++ b/DuckDuckGoTests/SubscriptionFlowViewModelTests.swift @@ -36,12 +36,14 @@ final class SubscriptionFlowViewModelTests: XCTestCase { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: mockDependencyProvider.subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) - + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) + // WHEN sut = .init(origin: origin, userScript: .init(), subFeature: .init(subscriptionManager: mockDependencyProvider.subscriptionManager, subscriptionAttributionOrigin: nil, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow), + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow), subscriptionManager: mockDependencyProvider.subscriptionManager) // THEN @@ -52,12 +54,14 @@ final class SubscriptionFlowViewModelTests: XCTestCase { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: mockDependencyProvider.subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) - + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) + // WHEN sut = .init(origin: nil, userScript: .init(), subFeature: .init(subscriptionManager: mockDependencyProvider.subscriptionManager, subscriptionAttributionOrigin: nil, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow), + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow), subscriptionManager: mockDependencyProvider.subscriptionManager) // THEN diff --git a/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift b/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift index 06e3378db6..a92804fe8e 100644 --- a/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift +++ b/DuckDuckGoTests/YoutublePlayerNavigationHandlerTests.swift @@ -40,11 +40,23 @@ class MockWKNavigationDelegate: NSObject, WKNavigationDelegate { decidePolicyForNavigationAction?(webView, navigationAction, decisionHandler) ?? decisionHandler(.allow) } } + class MockWebView: WKWebView { var didStopLoadingCalled = false var lastLoadedRequest: URLRequest? var lastResponseHTML: String? var goToCalledWith: WKBackForwardListItem? + var canGoBackMock = false + var currentURL: URL? + + private var _url: URL? + override var url: URL? { + return currentURL + } + + func setCurrentURL(_ url: URL) { + self.currentURL = url + } override func stopLoading() { didStopLoadingCalled = true @@ -55,13 +67,11 @@ class MockWebView: WKWebView { return nil } - override func go(to item: WKBackForwardListItem) -> WKNavigation? { - goToCalledWith = item + override func reload() -> WKNavigation? { return nil } } - class MockNavigationAction: WKNavigationAction { private let _request: URLRequest @@ -76,12 +86,12 @@ class MockNavigationAction: WKNavigationAction { class YoutubePlayerNavigationHandlerTests: XCTestCase { - + var handler: YoutubePlayerNavigationHandler! var webView: WKWebView! var mockWebView: MockWebView! var mockNavigationDelegate: MockWKNavigationDelegate! - + override func setUp() { super.setUp() handler = YoutubePlayerNavigationHandler() @@ -90,7 +100,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { mockNavigationDelegate = MockWKNavigationDelegate() webView.navigationDelegate = mockNavigationDelegate } - + override func tearDown() { webView.navigationDelegate = nil webView = nil @@ -111,16 +121,16 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { // Test for makeDuckPlayerRequest(from:) func testMakeDuckPlayerRequestFromOriginalRequest() { let originalRequest = URLRequest(url: URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")!) - + let duckPlayerRequest = YoutubePlayerNavigationHandler.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" @@ -134,7 +144,7 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { XCTAssertEqual(duckPlayerRequest.value(forHTTPHeaderField: "Referer"), "http://localhost/") XCTAssertEqual(duckPlayerRequest.httpMethod, "GET") } - + // Test for makeHTMLFromTemplate func testMakeHTMLFromTemplate() { let expectedHtml = try? String(contentsOfFile: YoutubePlayerNavigationHandler.htmlTemplatePath) @@ -142,11 +152,11 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { XCTAssertEqual(html, expectedHtml) } - // Validate redirects are properly triggered - func testHandleRedirect() { - + // Test for handleURLChange + @MainActor + func testHandleURLChange() { let youtubeURL = URL(string: "https://www.youtube.com/watch?v=abc123&t=10s")! - handler.handleRedirect(url: youtubeURL, webView: mockWebView) + handler.handleURLChange(url: youtubeURL, webView: mockWebView) XCTAssertTrue(mockWebView.didStopLoadingCalled, "Expected stopLoading to be called") XCTAssertNotNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") @@ -159,29 +169,29 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { } } - func testHandleRedirectForNonYouTubeVideo() { - - let youtubeURL = URL(string: "https://www.google.com.com/watch?v=abc123&t=10s")! - handler.handleRedirect(url: youtubeURL, webView: mockWebView) + @MainActor + func testHandleURLChangeForNonYouTubeVideo() { + let nonYouTubeURL = URL(string: "https://www.google.com")! + handler.handleURLChange(url: nonYouTubeURL, webView: mockWebView) XCTAssertFalse(mockWebView.didStopLoadingCalled, "Expected stopLoading not to be called") XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") - } - func testHandleRedirectWithNavigationAction() { - + // Test for handleDecidePolicyFor + @MainActor + func testHandleDecidePolicyFor() { 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") var navigationPolicy: WKNavigationActionPolicy? - handler.handleRedirect(navigationAction, completion: { policy in + 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") @@ -195,75 +205,50 @@ class YoutubePlayerNavigationHandlerTests: XCTestCase { } } - func testHandleRedirectWithNavigationActionForNonYouTubeVideo() { - - let youtubeURL = URL(string: "https://www.google.com.com/watch?v=abc123&t=10s")! - let navigationAction = MockNavigationAction(request: URLRequest(url: youtubeURL)) + @MainActor + func testHandleDecidePolicyForNonYouTubeVideo() { + let nonYouTubeURL = URL(string: "https://www.google.com")! + let navigationAction = MockNavigationAction(request: URLRequest(url: nonYouTubeURL)) let expectation = self.expectation(description: "Completion handler called") var navigationPolicy: WKNavigationActionPolicy? - handler.handleRedirect(navigationAction, completion: { policy in + 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") XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") } - // Test for handleNavigation for duck:// links - func testHandleNavigationWithDuckPlayerURL() { - let handler = YoutubePlayerNavigationHandler() - let mockWebView = MockWebView() - let duckPlayerURL = URL(string: "duck://player/abc123&t=30")! - let navigationAction = MockNavigationAction(request: URLRequest(url: duckPlayerURL)) - let expectation = self.expectation(description: "Completion handler called") - - var navigationPolicy: WKNavigationActionPolicy? - - handler.handleNavigation(navigationAction, webView: mockWebView) { policy in - navigationPolicy = policy - expectation.fulfill() - } - - waitForExpectations(timeout: 1, handler: nil) - - XCTAssertEqual(navigationPolicy, .allow, "Expected navigation policy to be .allow") + @MainActor + func testHandleReloadForDuckPlayerVideo() { + let duckPlayerURL = URL(string: "https://www.youtube-nocookie.com/embed/abc123?t=10s")! - if let responseHTML = mockWebView.lastResponseHTML { - let expectedHtml = try? String(contentsOfFile: YoutubePlayerNavigationHandler.htmlTemplatePath) - XCTAssertEqual(responseHTML, expectedHtml) - } - } - - // Test for handleNavigation for non duck:// links - func testHandleNavigationWithNonDuckPlayerURL() { - let handler = YoutubePlayerNavigationHandler() - let mockWebView = MockWebView() - let duckPlayerURL = URL(string: "https://www.youtube.com/watch?v=abc123")! - let navigationAction = MockNavigationAction(request: URLRequest(url: duckPlayerURL)) - let expectation = self.expectation(description: "Completion handler called") + mockWebView.setCurrentURL(duckPlayerURL) + handler.handleReload(webView: mockWebView) - var navigationPolicy: WKNavigationActionPolicy? + XCTAssertNotNil(mockWebView.lastLoadedRequest, "Expected a new request to be loaded") - handler.handleNavigation(navigationAction, webView: mockWebView) { policy in - navigationPolicy = policy - expectation.fulfill() + 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) } + } + + @MainActor + func testHandleReloadForNonDuckPlayerVideo() { + let nonDuckPlayerURL = URL(string: "https://www.google.com")! - waitForExpectations(timeout: 1, handler: nil) - - XCTAssertEqual(navigationPolicy, .cancel, "Expected navigation policy to be .cancel") + // Simulate the current URL + mockWebView.setCurrentURL(nonDuckPlayerURL) + handler.handleReload(webView: mockWebView) XCTAssertNil(mockWebView.lastLoadedRequest, "Expected a new request not to be loaded") - XCTAssertNil(mockWebView.lastResponseHTML, "Expected the response HTML not to be loaded") - - if let responseHTML = mockWebView.lastResponseHTML { - let expectedHtml = try? String(contentsOfFile: YoutubePlayerNavigationHandler.htmlTemplatePath) - XCTAssertEqual(responseHTML, expectedHtml) - } } - + } diff --git a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift index 54ae6ecda7..7b66f1efa8 100644 --- a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift +++ b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift @@ -144,6 +144,8 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { case .success: DailyPixel.fireDailyAndCount(pixel: .networkProtectionServerMigrationAttemptSuccess) } + case .tunnelStartOnDemandWithoutAccessToken: + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelStartAttemptOnDemandWithoutAccessToken) } } diff --git a/fastlane/metadata/default/release_notes.txt b/fastlane/metadata/default/release_notes.txt index 947a33a7e1..b4dad65e3a 100644 --- a/fastlane/metadata/default/release_notes.txt +++ b/fastlane/metadata/default/release_notes.txt @@ -1,2 +1,5 @@ -Bug fixes and other improvements. +- Bug fixes and other improvements. + +For Privacy Pro subscribers: +- You can now specify a custom DNS server under VPN Settings to route your DNS queries through while the VPN is active.