diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 18c6b0b761..1361e9d352 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -1885,7 +1885,7 @@ extension Pixel.Event { case .openAIChatFromAddressBar: return "m_aichat_addressbar_icon" // MARK: Lifecycle - case .appDidTransitionToUnexpectedState: return "m_debug_app-did-transition-to-unexpected-state" + case .appDidTransitionToUnexpectedState: return "m_debug_app-did-transition-to-unexpected-state-2" } } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 9017f26c33..393ffe9988 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -973,7 +973,13 @@ CB2A7EEF283D185100885F67 /* RulesCompilationMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2A7EEE283D185100885F67 /* RulesCompilationMonitor.swift */; }; CB2A7EF128410DF700885F67 /* PixelEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2A7EF028410DF700885F67 /* PixelEvent.swift */; }; CB2A7EF4285383B300885F67 /* AppLastCompiledRulesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2A7EF3285383B300885F67 /* AppLastCompiledRulesStore.swift */; }; - CB3C78912D08484800A7E4ED /* InactiveBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3C78902D08483F00A7E4ED /* InactiveBackground.swift */; }; + CB3C78892D06D3A700A7E4ED /* Active.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFC2CFE1D48006267B8 /* Active.swift */; }; + CB3C788A2D06D3A700A7E4ED /* Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F002CFE1D54006267B8 /* Background.swift */; }; + CB3C788B2D06D3A700A7E4ED /* Launched.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFA2CFE1D3F006267B8 /* Launched.swift */; }; + CB3C788C2D06D3A700A7E4ED /* AppStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F052CFE270D006267B8 /* AppStateMachine.swift */; }; + CB3C788D2D06D3A700A7E4ED /* AppStateTransitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F072CFE27D5006267B8 /* AppStateTransitions.swift */; }; + CB3C788E2D06D3A700A7E4ED /* Init.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EF82CFE1D35006267B8 /* Init.swift */; }; + CB3C788F2D06D3A700A7E4ED /* Inactive.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFE2CFE1D4E006267B8 /* Inactive.swift */; }; CB48D3332B90CE9F00631D8B /* PageRefreshStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB48D3312B90CE9F00631D8B /* PageRefreshStore.swift */; }; CB4FA44E2C78AACE00A16F5A /* SpecialErrorPageUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB4FA44D2C78AACE00A16F5A /* SpecialErrorPageUserScript.swift */; }; CB5516D0286500290079B175 /* TrackerRadarIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85519124247468580010FDD0 /* TrackerRadarIntegrationTests.swift */; }; @@ -989,13 +995,11 @@ CB9B873C278C8FEA001F4906 /* WidgetEducationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9B873B278C8FEA001F4906 /* WidgetEducationView.swift */; }; CB9B873E278C93C2001F4906 /* HomeMessage.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CB9B873D278C93C2001F4906 /* HomeMessage.xcassets */; }; CBAA195A27BFE15600A4BD49 /* NSManagedObjectContextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAA195927BFE15600A4BD49 /* NSManagedObjectContextExtension.swift */; }; - CBAD0EF92CFE1D3B006267B8 /* Init.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EF82CFE1D35006267B8 /* Init.swift */; }; - CBAD0EFB2CFE1D41006267B8 /* Launched.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFA2CFE1D3F006267B8 /* Launched.swift */; }; - CBAD0EFD2CFE1D4B006267B8 /* Active.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFC2CFE1D48006267B8 /* Active.swift */; }; - CBAD0EFF2CFE1D50006267B8 /* Inactive.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFE2CFE1D4E006267B8 /* Inactive.swift */; }; - CBAD0F012CFE1D57006267B8 /* Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F002CFE1D54006267B8 /* Background.swift */; }; - CBAD0F062CFE2711006267B8 /* AppStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F052CFE270D006267B8 /* AppStateMachine.swift */; }; - CBAD0F082CFE27E2006267B8 /* AppStateTransitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F072CFE27D5006267B8 /* AppStateTransitions.swift */; }; + CBAD0F0A2CFF418F006267B8 /* AppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F092CFF4185006267B8 /* AppShortcuts.swift */; }; + CBAD0F0C2CFF4EE1006267B8 /* AppDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F0B2CFF4EDD006267B8 /* AppDependencies.swift */; }; + CBAD0F102D0062A7006267B8 /* UIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F0F2D0062A3006267B8 /* UIService.swift */; }; + CBAD0F122D00F1C8006267B8 /* UNService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F112D00F1C8006267B8 /* UNService.swift */; }; + CBAD0F142D01EE45006267B8 /* SubscriptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F132D01EE40006267B8 /* SubscriptionService.swift */; }; CBC83E3429B631780008E19C /* Configuration in Frameworks */ = {isa = PBXBuildFile; productRef = CBC83E3329B631780008E19C /* Configuration */; }; CBC88EE12C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */; }; CBC88EE52C8097B500F0F8C5 /* URLCredentialCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE42C8097B500F0F8C5 /* URLCredentialCreator.swift */; }; @@ -1005,6 +1009,9 @@ CBD4F13E279EBFAB00B20FD7 /* HomeMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF14FC227970072001D94D0 /* HomeMessageView.swift */; }; CBD4F13F279EBFAF00B20FD7 /* HomeMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF14FC427970AB0001D94D0 /* HomeMessageViewModel.swift */; }; CBD4F140279EBFB300B20FD7 /* SwiftUICollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB1AEFB02799AA940031AE3D /* SwiftUICollectionViewCell.swift */; }; + CBD79F482D1061DA00DBB45A /* NewAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBD79F472D1061D500DBB45A /* NewAppDelegate.swift */; }; + CBD79F4A2D1061E200DBB45A /* OldAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBD79F492D1061DF00DBB45A /* OldAppDelegate.swift */; }; + CBD79F4D2D130F6500DBB45A /* Testing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBD79F4C2D130F6300DBB45A /* Testing.swift */; }; CBDD5DDF29A6736A00832877 /* APIHeadersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDD5DDE29A6736A00832877 /* APIHeadersTests.swift */; }; CBDD5DE129A6741300832877 /* MockBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDD5DE029A6741300832877 /* MockBundle.swift */; }; CBECDB6F2CD3DFBE005B8B87 /* PageRefreshMonitor in Frameworks */ = {isa = PBXBuildFile; productRef = CBECDB6E2CD3DFBE005B8B87 /* PageRefreshMonitor */; }; @@ -2869,7 +2876,6 @@ CB2A7EF028410DF700885F67 /* PixelEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PixelEvent.swift; sourceTree = ""; }; CB2A7EF3285383B300885F67 /* AppLastCompiledRulesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLastCompiledRulesStore.swift; sourceTree = ""; }; CB2C47822AF6D55800AEDCD9 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; - CB3C78902D08483F00A7E4ED /* InactiveBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactiveBackground.swift; sourceTree = ""; }; CB4448752AF6D51D001F93F7 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/InfoPlist.strings; sourceTree = ""; }; CB48D3312B90CE9F00631D8B /* PageRefreshStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PageRefreshStore.swift; sourceTree = ""; }; CB4FA44D2C78AACE00A16F5A /* SpecialErrorPageUserScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageUserScript.swift; sourceTree = ""; }; @@ -2897,12 +2903,20 @@ CBAD0F002CFE1D54006267B8 /* Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Background.swift; sourceTree = ""; }; CBAD0F052CFE270D006267B8 /* AppStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateMachine.swift; sourceTree = ""; }; CBAD0F072CFE27D5006267B8 /* AppStateTransitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTransitions.swift; sourceTree = ""; }; + CBAD0F092CFF4185006267B8 /* AppShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = ""; }; + CBAD0F0B2CFF4EDD006267B8 /* AppDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDependencies.swift; sourceTree = ""; }; + CBAD0F0F2D0062A3006267B8 /* UIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIService.swift; sourceTree = ""; }; + CBAD0F112D00F1C8006267B8 /* UNService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNService.swift; sourceTree = ""; }; + CBAD0F132D01EE40006267B8 /* SubscriptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionService.swift; sourceTree = ""; }; CBB6B2542AF6D543006B777C /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lt; path = lt.lproj/InfoPlist.strings; sourceTree = ""; }; CBC7AB542AF6D583008CB798 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageUserScriptTests.swift; sourceTree = ""; }; CBC88EE42C8097B500F0F8C5 /* URLCredentialCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLCredentialCreator.swift; sourceTree = ""; }; CBC8DC252AF6D4CD00BA681A /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; CBD4F13B279EBF4A00B20FD7 /* HomeMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeMessage.swift; sourceTree = ""; }; + CBD79F472D1061D500DBB45A /* NewAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewAppDelegate.swift; sourceTree = ""; }; + CBD79F492D1061DF00DBB45A /* OldAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldAppDelegate.swift; sourceTree = ""; }; + CBD79F4C2D130F6300DBB45A /* Testing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Testing.swift; sourceTree = ""; }; CBD7AE812AF6D5B6009052FD /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; CBDD5DDE29A6736A00832877 /* APIHeadersTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIHeadersTests.swift; sourceTree = ""; }; CBDD5DE029A6741300832877 /* MockBundle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockBundle.swift; sourceTree = ""; }; @@ -5597,7 +5611,7 @@ CBAD0EFC2CFE1D48006267B8 /* Active.swift */, CBAD0EFE2CFE1D4E006267B8 /* Inactive.swift */, CBAD0F002CFE1D54006267B8 /* Background.swift */, - CB3C78902D08483F00A7E4ED /* InactiveBackground.swift */, + CBD79F4C2D130F6300DBB45A /* Testing.swift */, ); path = AppStates; sourceTree = ""; @@ -5612,6 +5626,16 @@ path = AppLifecycle; sourceTree = ""; }; + CBAD0F0E2D006291006267B8 /* AppServices */ = { + isa = PBXGroup; + children = ( + CBAD0F0F2D0062A3006267B8 /* UIService.swift */, + CBAD0F112D00F1C8006267B8 /* UNService.swift */, + CBAD0F132D01EE40006267B8 /* SubscriptionService.swift */, + ); + path = AppServices; + sourceTree = ""; + }; D62EC3B72C24695800FC9D04 /* DuckPlayer */ = { isa = PBXGroup; children = ( @@ -6503,10 +6527,15 @@ F1C5ECF31E37812900C599A4 /* Application */ = { isa = PBXGroup; children = ( + CBAD0F0E2D006291006267B8 /* AppServices */, + CBAD0F0B2CFF4EDD006267B8 /* AppDependencies.swift */, + CBAD0F092CFF4185006267B8 /* AppShortcuts.swift */, CBAD0F042CFE1DA2006267B8 /* AppLifecycle */, 83BE9BC2215D69C1009844D9 /* AppConfigurationFetch.swift */, CB24F70E29A3EB15006DCC58 /* AppConfigurationURLProvider.swift */, 84E341951E2F7EFB00BDBA6F /* AppDelegate.swift */, + CBD79F492D1061DF00DBB45A /* OldAppDelegate.swift */, + CBD79F472D1061D500DBB45A /* NewAppDelegate.swift */, 85DB12EC2A1FED0C000A4A72 /* AppDelegate+AppDeepLinks.swift */, 98B31291218CCB8C00E54DE1 /* AppDependencyProvider.swift */, 85BA58591F3506AE00C6E8CA /* AppSettings.swift */, @@ -7681,7 +7710,6 @@ BDE91CDE2C62B90F0005CB74 /* UnifiedFeedbackRootView.swift in Sources */, D65625A12C232F5E006EF297 /* SettingsDuckPlayerView.swift in Sources */, D6FEB8B52B74994000C3615F /* HeadlessWebViewCoordinator.swift in Sources */, - CBAD0EF92CFE1D3B006267B8 /* Init.swift in Sources */, 9F96F73F2C914C57009E45D5 /* OnboardingGradient.swift in Sources */, 6FE1273D2C204C2500EB5724 /* FavoritesView.swift in Sources */, 8528AE81212F15D600D0BD74 /* AppRatingPrompt.xcdatamodeld in Sources */, @@ -7701,6 +7729,7 @@ B623C1C42862CD670043013E /* WKDownloadSession.swift in Sources */, 6FD1BAE42B87A107000C475C /* AdAttributionPixelReporter.swift in Sources */, 1E8AD1D927C4FEC100ABA377 /* DownloadsListSectioningHelper.swift in Sources */, + CBD79F4A2D1061E200DBB45A /* OldAppDelegate.swift in Sources */, D60170BD2BA34CE8001911B5 /* Subscription.swift in Sources */, 1E4DCF4827B6A35400961E25 /* DownloadsListModel.swift in Sources */, C12726F02A5FF89900215B02 /* EmailSignupPromptViewModel.swift in Sources */, @@ -7718,7 +7747,6 @@ 8590CB69268A4E190089F6BF /* DebugEtagStorage.swift in Sources */, C1CDA3162AFB9C7F006D1476 /* AutofillNeverPromptWebsitesManager.swift in Sources */, D668D9272B6937D2008E2FF2 /* SubscriptionITPViewModel.swift in Sources */, - CBAD0F012CFE1D57006267B8 /* Background.swift in Sources */, F1CA3C371F045878005FADB3 /* PrivacyStore.swift in Sources */, 31DE43C42C2C60E800F8C51F /* DuckPlayerModalPresenter.swift in Sources */, 37FCAAC029930E26000E420A /* FailedAssertionView.swift in Sources */, @@ -7755,7 +7783,15 @@ 9FEA22272C2D2BDA006B03BF /* RootDebugViewController+Onboarding.swift in Sources */, 319A37152829A55F0079FBCE /* AutofillListItemTableViewCell.swift in Sources */, 6F8496412BC3D8EE00ADA54E /* OnboardingButtonsView.swift in Sources */, + CB3C78892D06D3A700A7E4ED /* Active.swift in Sources */, + CB3C788A2D06D3A700A7E4ED /* Background.swift in Sources */, + CB3C788B2D06D3A700A7E4ED /* Launched.swift in Sources */, + CB3C788C2D06D3A700A7E4ED /* AppStateMachine.swift in Sources */, + CB3C788D2D06D3A700A7E4ED /* AppStateTransitions.swift in Sources */, + CB3C788E2D06D3A700A7E4ED /* Init.swift in Sources */, + CB3C788F2D06D3A700A7E4ED /* Inactive.swift in Sources */, 1EA513782866039400493C6A /* TrackerAnimationLogic.swift in Sources */, + CBD79F482D1061DA00DBB45A /* NewAppDelegate.swift in Sources */, 854A01332A558B3A00FCC628 /* UIView+Constraints.swift in Sources */, 9FB0271B2C2927D0009EA190 /* OnboardingView.swift in Sources */, C12726EE2A5FF88C00215B02 /* EmailSignupPromptView.swift in Sources */, @@ -7785,7 +7821,6 @@ BDFF031D2BA3D2BD00F324C9 /* DefaultNetworkProtectionVisibility.swift in Sources */, F1BE54581E69DE1000FCF649 /* TutorialSettings.swift in Sources */, 1EE52ABB28FB1D6300B750C1 /* UIImageExtension.swift in Sources */, - CBAD0EFF2CFE1D50006267B8 /* Inactive.swift in Sources */, 858650D12469BCDE00C36F8A /* DaxDialogs.swift in Sources */, 9F5E5AB02C3E4C6000165F54 /* ContextualOnboardingPresenter.swift in Sources */, 310D091B2799F54900DC0060 /* DownloadManager.swift in Sources */, @@ -7825,7 +7860,6 @@ 859DB8132CE6263C001F7210 /* TextZoomStorage.swift in Sources */, D65625952C22D382006EF297 /* TabViewController.swift in Sources */, 8C4838B5221C8F7F008A6739 /* GestureToolbarButton.swift in Sources */, - CBAD0EFD2CFE1D4B006267B8 /* Active.swift in Sources */, 310ECFDD282A8BB0005029B3 /* EnableAutofillSettingsTableViewCell.swift in Sources */, 859DB8172CE6263C001F7210 /* TextZoomLevel.swift in Sources */, BDE91CD62C6294020005CB74 /* FeedbackCategoryProviding.swift in Sources */, @@ -7846,6 +7880,7 @@ 8505836E219F424500ED4EDB /* RoundedRectangleView.swift in Sources */, EE8594992A44791C008A6D06 /* NetworkProtectionTunnelController.swift in Sources */, 1EEF123F2850A68A003DDE57 /* PrivacyInfoContainerView.swift in Sources */, + CBAD0F122D00F1C8006267B8 /* UNService.swift in Sources */, F4B0B796252CB35700830156 /* OnboardingWidgetsDetailsViewController.swift in Sources */, CB258D1329A4F24E00DEBA24 /* ConfigurationStore.swift in Sources */, 85058370219F424500ED4EDB /* SearchBarExtension.swift in Sources */, @@ -7921,7 +7956,6 @@ D6F93E3E2B50A8A0004C268D /* SubscriptionSettingsView.swift in Sources */, 1D200C9B2BA31A6A00108701 /* AboutView.swift in Sources */, 851B12CC22369931004781BC /* AtbAndVariantCleanup.swift in Sources */, - CBAD0F062CFE2711006267B8 /* AppStateMachine.swift in Sources */, D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */, 85F2FFCF2211F8E5006BB258 /* TabSwitcherViewController+KeyCommands.swift in Sources */, 3157B43327F497E90042D3D7 /* SaveLoginView.swift in Sources */, @@ -8014,6 +8048,7 @@ 9F8E0F2F2CCA6202001EA7C5 /* VideoPlayerViewModel.swift in Sources */, 98D98A8225ED88E300D8E3DF /* BrowsingMenuSeparatorViewCell.swift in Sources */, D63657192A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift in Sources */, + CBAD0F142D01EE45006267B8 /* SubscriptionService.swift in Sources */, 1E4FAA6427D8DFB900ADC5B3 /* OngoingDownloadRowViewModel.swift in Sources */, 8C4724502217A14B004C9B2D /* TabViewControllerLongPressBookmarkExtension.swift in Sources */, 564DE4532C3ED1B700D23241 /* NewTabDaxDialogFactory.swift in Sources */, @@ -8022,6 +8057,7 @@ 85047C772A0D5D3D00D2FF3F /* SyncSettingsViewController+SyncDelegate.swift in Sources */, 85DDE0402AC6FF65006ABCA2 /* MainView.swift in Sources */, 980891A72237D5D800313A70 /* FeedbackPresenter.swift in Sources */, + CBAD0F0C2CFF4EE1006267B8 /* AppDependencies.swift in Sources */, 989B337522D7EF2100437824 /* EmptyCollectionReusableView.swift in Sources */, 0283A1FE2C6E3E1B00508FBD /* BrokenSitePromptViewModel.swift in Sources */, 8524CC94246C5C8900E59D45 /* DaxDialogViewController.swift in Sources */, @@ -8033,6 +8069,7 @@ 8598D2E02CEB98B500C45685 /* Favicons.swift in Sources */, 8598D2E12CEB98B500C45685 /* NotFoundCachingDownloader.swift in Sources */, 8598D2E22CEB98B500C45685 /* FaviconRequestModifier.swift in Sources */, + CBAD0F102D0062A7006267B8 /* UIService.swift in Sources */, 8598D2E32CEB98B500C45685 /* FaviconUserScript.swift in Sources */, 8598D2E42CEB98B500C45685 /* FaviconSourcesProvider.swift in Sources */, BD862E052B30DB250073E2EE /* VPNFeedbackCategory.swift in Sources */, @@ -8052,6 +8089,7 @@ 98DA6ECA2181E41F00E65433 /* ThemeManager.swift in Sources */, F1D43AFC2B99C56000BAB743 /* RootDebugViewController+VanillaBrowser.swift in Sources */, C159DF072A430B60007834BB /* EmailSignupViewController.swift in Sources */, + CBD79F4D2D130F6500DBB45A /* Testing.swift in Sources */, 37A6A8FE2AFD0208008580A3 /* FaviconsFetcherOnboarding.swift in Sources */, F1CA3C3B1F045B65005FADB3 /* Authenticator.swift in Sources */, CBD4F13D279EBFA000B20FD7 /* HomeMessageCollectionViewCell.swift in Sources */, @@ -8115,7 +8153,6 @@ 311BD1B12836C0CA00AEF6C1 /* AutofillLoginListAuthenticator.swift in Sources */, B652DF13287C373A00C12A9C /* ScriptSourceProviding.swift in Sources */, 854A012B2A54412600FCC628 /* ActivityViewController.swift in Sources */, - CBAD0F082CFE27E2006267B8 /* AppStateTransitions.swift in Sources */, F1CA3C391F045885005FADB3 /* PrivacyUserDefaults.swift in Sources */, 6F655BE22BAB289E00AC3597 /* DefaultTheme.swift in Sources */, 6FE1274B2C20943500EB5724 /* ShortcutItemView.swift in Sources */, @@ -8199,11 +8236,11 @@ 314C92B827C3DD660042EC96 /* QuickLookPreviewView.swift in Sources */, 6F5345AF2C53F2DE00424A43 /* NewTabPageSettingsPersistentStorage.swift in Sources */, F1AE54E81F0425FC00D9A700 /* AuthenticationViewController.swift in Sources */, + CBAD0F0A2CFF418F006267B8 /* AppShortcuts.swift in Sources */, 560E990F2BEE2CB800507CE0 /* SyncErrorMessage.swift in Sources */, 983D71B12A286E810072E26D /* SyncDebugViewController.swift in Sources */, 6FDA1FB32B59584400AC962A /* AddressDisplayHelper.swift in Sources */, F103073B1E7C91330059FEC7 /* BookmarksDataSource.swift in Sources */, - CBAD0EFB2CFE1D41006267B8 /* Launched.swift in Sources */, 6FD3F80F2C3EF4F000DA5797 /* DeviceOrientationEnvironmentValue.swift in Sources */, 85864FBC24D31EF300E756FF /* SuggestionTrayViewController.swift in Sources */, D64648AF2B5993890033090B /* SubscriptionEmailViewModel.swift in Sources */, @@ -8215,7 +8252,6 @@ 1DEAADF42BA47B5300E25A97 /* WebTrackingProtectionView.swift in Sources */, F15D43201E706CC500BF2CDC /* AutocompleteViewController.swift in Sources */, BD862E092B30F63E0073E2EE /* VPNMetadataCollector.swift in Sources */, - CB3C78912D08484800A7E4ED /* InactiveBackground.swift in Sources */, D6E83C682B23B6A3006C8AFB /* FontSettings.swift in Sources */, 7BF78E022CA2CC3E0026A1FC /* TipKitAppEventHandling.swift in Sources */, 1DEAADF62BA4809400E25A97 /* CookiePopUpProtectionView.swift in Sources */, @@ -11718,8 +11754,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { - kind = exactVersion; - version = 221.3.0; + branch = "jacek/refactor-app-delegate"; + kind = branch; }; }; 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 4eaad818db..23175cdd4f 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" : "b71ed70ce9b0ef3ce51d4f96da0193ab70493944", - "version" : "221.3.0" + "branch" : "jacek/refactor-app-delegate", + "revision" : "3712ccc0b6867e08b6235083f7e754d537f0b5cb" } }, { diff --git a/DuckDuckGo/AppDelegate+AppDeepLinks.swift b/DuckDuckGo/AppDelegate+AppDeepLinks.swift index 280b7c32bb..8b59faa969 100644 --- a/DuckDuckGo/AppDelegate+AppDeepLinks.swift +++ b/DuckDuckGo/AppDelegate+AppDeepLinks.swift @@ -20,7 +20,7 @@ import UIKit import Core -extension AppDelegate { +extension OldAppDelegate { func handleAppDeepLink(_ app: UIApplication, _ mainViewController: MainViewController?, _ url: URL) -> Bool { guard let mainViewController else { return false } diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 38ff685038..2f420be64f 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -18,900 +18,88 @@ // import UIKit -import Combine -import Common import Core -import UserNotifications -import Kingfisher -import WidgetKit -import BackgroundTasks -import BrowserServicesKit -import Bookmarks -import Persistence -import Crashes -import Configuration -import Networking -import DDGSync -import RemoteMessaging -import SyncDataProviders -import Subscription -import NetworkProtection -import PixelKit -import PixelExperimentKit -import WebKit -import os.log -@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - - private static let ShowKeyboardOnLaunchThreshold = TimeInterval(20) - private struct ShortcutKey { - static let clipboard = "com.duckduckgo.mobile.ios.clipboard" - static let passwords = "com.duckduckgo.mobile.ios.passwords" - static let openVPNSettings = "com.duckduckgo.mobile.ios.vpn.open-settings" - } - - private var testing = false - var appIsLaunching = false - var overlayWindow: UIWindow? - var window: UIWindow? - - private lazy var privacyStore = PrivacyUserDefaults() - private var bookmarksDatabase: CoreDataDatabase = BookmarksDatabase.make() - - private let widgetRefreshModel = NetworkProtectionWidgetRefreshModel() - private let tunnelDefaults = UserDefaults.networkProtectionGroupDefaults - - @MainActor - private lazy var vpnWorkaround: VPNRedditSessionWorkaround = { - return VPNRedditSessionWorkaround( - accountManager: AppDependencyProvider.shared.accountManager, - tunnelController: AppDependencyProvider.shared.networkProtectionTunnelController - ) - }() - - private var autoClear: AutoClear? - private var showKeyboardIfSettingOn = true - private var lastBackgroundDate: Date? - - private(set) var homePageConfiguration: HomePageConfiguration! - - private(set) var remoteMessagingClient: RemoteMessagingClient! - - private(set) var syncService: DDGSync! - private(set) var syncDataProviders: SyncDataProviders! - private var syncDidFinishCancellable: AnyCancellable? - private var syncStateCancellable: AnyCancellable? - private var isSyncInProgressCancellable: AnyCancellable? - - private let crashCollection = CrashCollection(platform: .iOS) - private var crashReportUploaderOnboarding: CrashCollectionOnboarding? - - private var autofillPixelReporter: AutofillPixelReporter? - private var autofillUsageMonitor = AutofillUsageMonitor() - - private(set) var subscriptionFeatureAvailability: SubscriptionFeatureAvailability! - private var subscriptionCookieManager: SubscriptionCookieManaging! - private var subscriptionCookieManagerFeatureFlagCancellable: AnyCancellable? - var privacyProDataReporter: PrivacyProDataReporting! - - // MARK: - Feature specific app event handlers - - private let tipKitAppEventsHandler = TipKitAppEventHandler() - - // MARK: lifecycle - - @UserDefaultsWrapper(key: .privacyConfigCustomURL, defaultValue: nil) - private var privacyConfigCustomURL: String? - - var accountManager: AccountManager { - AppDependencyProvider.shared.accountManager - } - - @UserDefaultsWrapper(key: .didCrashDuringCrashHandlersSetUp, defaultValue: false) - private var didCrashDuringCrashHandlersSetUp: Bool - - private let launchOptionsHandler = LaunchOptionsHandler() - private let onboardingPixelReporter = OnboardingPixelReporter() - - private let voiceSearchHelper = VoiceSearchHelper() - - private let marketplaceAdPostbackManager = MarketplaceAdPostbackManager() - - private var didFinishLaunchingStartTime: CFAbsoluteTime? - - private let appStateMachine = AppStateMachine() - - override init() { - super.init() - - if !didCrashDuringCrashHandlersSetUp { - didCrashDuringCrashHandlersSetUp = true - CrashLogMessageExtractor.setUp(swapCxaThrow: false) - didCrashDuringCrashHandlersSetUp = false - } - } - - // swiftlint:disable:next function_body_length - // swiftlint:disable:next cyclomatic_complexity - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - - appStateMachine.handle(.launching(application, launchOptions: launchOptions)) - didFinishLaunchingStartTime = CFAbsoluteTimeGetCurrent() - defer { - if let didFinishLaunchingStartTime { - let launchTime = CFAbsoluteTimeGetCurrent() - didFinishLaunchingStartTime - Pixel.fire(pixel: .appDidFinishLaunchingTime(time: Pixel.Event.BucketAggregation(number: launchTime)), - withAdditionalParameters: [PixelParameters.time: String(launchTime)]) - } - } - - -#if targetEnvironment(simulator) - if ProcessInfo.processInfo.environment["UITESTING"] == "true" { - // Disable hardware keyboards. - let setHardwareLayout = NSSelectorFromString("setHardwareLayout:") - UITextInputMode.activeInputModes - // Filter `UIKeyboardInputMode`s. - .filter({ $0.responds(to: setHardwareLayout) }) - .forEach { $0.perform(setHardwareLayout, with: nil) } - } -#endif - -#if DEBUG - Pixel.isDryRun = true -#else - Pixel.isDryRun = false -#endif - - ContentBlocking.shared.onCriticalError = presentPreemptiveCrashAlert - // Explicitly prepare ContentBlockingUpdating instance before Tabs are created - _ = ContentBlockingUpdating.shared - - // Can be removed after a couple of versions - cleanUpMacPromoExperiment2() - cleanUpIncrementalRolloutPixelTest() - - APIRequest.Headers.setUserAgent(DefaultUserAgentManager.duckDuckGoUserAgent) - - if isDebugBuild, let privacyConfigCustomURL, let url = URL(string: privacyConfigCustomURL) { - Configuration.setURLProvider(CustomConfigurationURLProvider(customPrivacyConfigurationURL: url)) - } else { - Configuration.setURLProvider(AppConfigurationURLProvider()) - } - - crashCollection.startAttachingCrashLogMessages { pixelParameters, payloads, sendReport in - pixelParameters.forEach { params in - Pixel.fire(pixel: .dbCrashDetected, withAdditionalParameters: params, includedParameters: []) - - // Each crash comes with an `appVersion` parameter representing the version that the crash occurred on. - // This is to disambiguate the situation where a crash occurs, but isn't sent until the next update. - // If for some reason the parameter can't be found, fall back to the current version. - if let crashAppVersion = params[PixelParameters.appVersion] { - let dailyParameters = [PixelParameters.appVersion: crashAppVersion] - DailyPixel.fireDaily(.dbCrashDetectedDaily, withAdditionalParameters: dailyParameters) - } else { - DailyPixel.fireDaily(.dbCrashDetectedDaily) - } - } - - // Async dispatch because rootViewController may otherwise be nil here - DispatchQueue.main.async { - guard let viewController = self.window?.rootViewController else { return } - - let crashReportUploaderOnboarding = CrashCollectionOnboarding(appSettings: AppDependencyProvider.shared.appSettings) - crashReportUploaderOnboarding.presentOnboardingIfNeeded(for: payloads, from: viewController, sendReport: sendReport) - self.crashReportUploaderOnboarding = crashReportUploaderOnboarding - } - } - - clearTmp() - - _ = DefaultUserAgentManager.shared - testing = ProcessInfo().arguments.contains("testing") - if testing { - Pixel.isDryRun = true - _ = DefaultUserAgentManager.shared - Database.shared.loadStore { _, _ in } - _ = BookmarksDatabaseSetup().loadStoreAndMigrate(bookmarksDatabase: bookmarksDatabase) - - window = UIWindow(frame: UIScreen.main.bounds) - window?.rootViewController = UIStoryboard.init(name: "LaunchScreen", bundle: nil).instantiateInitialViewController() - - let blockingDelegate = BlockingNavigationDelegate() - let webView = blockingDelegate.prepareWebView() - window?.rootViewController?.view.addSubview(webView) - window?.rootViewController?.view.backgroundColor = .red - webView.frame = CGRect(x: 10, y: 10, width: 300, height: 300) - - let request = URLRequest(url: URL(string: "about:blank")!) - webView.load(request) - - return true - } - - removeEmailWaitlistState() - - var shouldPresentInsufficientDiskSpaceAlertAndCrash = false - Database.shared.loadStore { context, error in - guard let context = context else { - - let parameters = [PixelParameters.applicationState: "\(application.applicationState.rawValue)", - PixelParameters.dataAvailability: "\(application.isProtectedDataAvailable)"] - - switch error { - case .none: - fatalError("Could not create database stack: Unknown Error") - case .some(CoreDataDatabase.Error.containerLocationCouldNotBePrepared(let underlyingError)): - Pixel.fire(pixel: .dbContainerInitializationError, - error: underlyingError, - withAdditionalParameters: parameters) - Thread.sleep(forTimeInterval: 1) - fatalError("Could not create database stack: \(underlyingError.localizedDescription)") - case .some(let error): - Pixel.fire(pixel: .dbInitializationError, - error: error, - withAdditionalParameters: parameters) - if error.isDiskFull { - shouldPresentInsufficientDiskSpaceAlertAndCrash = true - return - } else { - Thread.sleep(forTimeInterval: 1) - fatalError("Could not create database stack: \(error.localizedDescription)") - } - } - } - DatabaseMigration.migrate(to: context) - } - - switch BookmarksDatabaseSetup().loadStoreAndMigrate(bookmarksDatabase: bookmarksDatabase) { - case .success: - break - case .failure(let error): - Pixel.fire(pixel: .bookmarksCouldNotLoadDatabase, - error: error) - if error.isDiskFull { - shouldPresentInsufficientDiskSpaceAlertAndCrash = true - } else { - Thread.sleep(forTimeInterval: 1) - fatalError("Could not create database stack: \(error.localizedDescription)") - } - } - - WidgetCenter.shared.reloadAllTimelines() - - Favicons.shared.migrateFavicons(to: Favicons.Constants.maxFaviconSize) { - WidgetCenter.shared.reloadAllTimelines() - } - - PrivacyFeatures.httpsUpgrade.loadDataAsync() - - let variantManager = DefaultVariantManager() - let daxDialogs = DaxDialogs.shared - - // assign it here, because "did become active" is already too late and "viewWillAppear" - // has already been called on the HomeViewController so won't show the home row CTA - cleanUpATBAndAssignVariant(variantManager: variantManager, daxDialogs: daxDialogs) - - // MARK: Sync initialisation -#if DEBUG - let defaultEnvironment = ServerEnvironment.development -#else - let defaultEnvironment = ServerEnvironment.production -#endif - - let environment = ServerEnvironment( - UserDefaultsWrapper( - key: .syncEnvironment, - defaultValue: defaultEnvironment.description - ).wrappedValue - ) ?? defaultEnvironment - - var dryRun = false -#if DEBUG - dryRun = true -#endif - let isPhone = UIDevice.current.userInterfaceIdiom == .phone - let source = isPhone ? PixelKit.Source.iOS : PixelKit.Source.iPadOS - PixelKit.setUp(dryRun: dryRun, - appVersion: AppVersion.shared.versionNumber, - source: source.rawValue, - defaultHeaders: [:], - defaults: UserDefaults(suiteName: "\(Global.groupIdPrefix).app-configuration") ?? UserDefaults()) { (pixelName: String, headers: [String: String], parameters: [String: String], _, _, onComplete: @escaping PixelKit.CompletionBlock) in - - let url = URL.pixelUrl(forPixelNamed: pixelName) - let apiHeaders = APIRequestV2.HeadersV2(additionalHeaders: headers) - let request = APIRequestV2(url: url, method: .get, queryItems: parameters, headers: apiHeaders) - Task { - do { - _ = try await DefaultAPIService().fetch(request: request) - onComplete(true, nil) - } catch { - onComplete(false, error) - } - } - } - PixelKit.configureExperimentKit(featureFlagger: AppDependencyProvider.shared.featureFlagger, - eventTracker: ExperimentEventTracker(store: UserDefaults(suiteName: "\(Global.groupIdPrefix).app-configuration") ?? UserDefaults())) - - let syncErrorHandler = SyncErrorHandler() - - syncDataProviders = SyncDataProviders( - bookmarksDatabase: bookmarksDatabase, - secureVaultErrorReporter: SecureVaultReporter(), - settingHandlers: [FavoritesDisplayModeSyncHandler()], - favoritesDisplayModeStorage: FavoritesDisplayModeStorage(), - syncErrorHandler: syncErrorHandler, - faviconStoring: Favicons.shared, - tld: AppDependencyProvider.shared.storageCache.tld - ) - - let syncService = DDGSync( - dataProvidersSource: syncDataProviders, - errorEvents: SyncErrorHandler(), - privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, - environment: environment - ) - syncService.initializeIfNeeded() - self.syncService = syncService - - let fireproofing = UserDefaultsFireproofing.xshared - privacyProDataReporter = PrivacyProDataReporter(fireproofing: fireproofing) - - isSyncInProgressCancellable = syncService.isSyncInProgressPublisher - .filter { $0 } - .sink { [weak syncService] _ in - DailyPixel.fire(pixel: .syncDaily, includedParameters: [.appVersion]) - syncService?.syncDailyStats.sendStatsIfNeeded(handler: { params in - Pixel.fire(pixel: .syncSuccessRateDaily, - withAdditionalParameters: params, - includedParameters: [.appVersion]) - }) - } - - remoteMessagingClient = RemoteMessagingClient( - bookmarksDatabase: bookmarksDatabase, - appSettings: AppDependencyProvider.shared.appSettings, - internalUserDecider: AppDependencyProvider.shared.internalUserDecider, - configurationStore: AppDependencyProvider.shared.configurationStore, - database: Database.shared, - errorEvents: RemoteMessagingStoreErrorHandling(), - remoteMessagingAvailabilityProvider: PrivacyConfigurationRemoteMessagingAvailabilityProvider( - privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager - ), - duckPlayerStorage: DefaultDuckPlayerStorage() - ) - remoteMessagingClient.registerBackgroundRefreshTaskHandler() - - subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability( - privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, - purchasePlatform: .appStore) +enum AppBehavior: String { - subscriptionCookieManager = makeSubscriptionCookieManager() + case old + case new - homePageConfiguration = HomePageConfiguration(variantManager: AppDependencyProvider.shared.variantManager, - remoteMessagingClient: remoteMessagingClient, - privacyProDataReporter: privacyProDataReporter) - - let previewsSource = TabPreviewsSource() - let historyManager = makeHistoryManager() - let tabsModel = prepareTabsModel(previewsSource: previewsSource) - - privacyProDataReporter.injectTabsModel(tabsModel) - - if shouldPresentInsufficientDiskSpaceAlertAndCrash { - - window = UIWindow(frame: UIScreen.main.bounds) - window?.rootViewController = BlankSnapshotViewController(appSettings: AppDependencyProvider.shared.appSettings, - voiceSearchHelper: voiceSearchHelper) - window?.makeKeyAndVisible() - - presentInsufficientDiskSpaceAlert() - } else { - let daxDialogsFactory = ExperimentContextualDaxDialogsFactory(contextualOnboardingLogic: daxDialogs, contextualOnboardingPixelReporter: onboardingPixelReporter) - let contextualOnboardingPresenter = ContextualOnboardingPresenter(variantManager: variantManager, daxDialogsFactory: daxDialogsFactory) - let main = MainViewController(bookmarksDatabase: bookmarksDatabase, - bookmarksDatabaseCleaner: syncDataProviders.bookmarksAdapter.databaseCleaner, - historyManager: historyManager, - homePageConfiguration: homePageConfiguration, - syncService: syncService, - syncDataProviders: syncDataProviders, - appSettings: AppDependencyProvider.shared.appSettings, - previewsSource: previewsSource, - tabsModel: tabsModel, - syncPausedStateManager: syncErrorHandler, - privacyProDataReporter: privacyProDataReporter, - variantManager: variantManager, - contextualOnboardingPresenter: contextualOnboardingPresenter, - contextualOnboardingLogic: daxDialogs, - contextualOnboardingPixelReporter: onboardingPixelReporter, - subscriptionFeatureAvailability: subscriptionFeatureAvailability, - voiceSearchHelper: voiceSearchHelper, - featureFlagger: AppDependencyProvider.shared.featureFlagger, - fireproofing: fireproofing, - subscriptionCookieManager: subscriptionCookieManager, - textZoomCoordinator: makeTextZoomCoordinator(), - websiteDataManager: makeWebsiteDataManager(fireproofing: fireproofing), - appDidFinishLaunchingStartTime: didFinishLaunchingStartTime) - - main.loadViewIfNeeded() - syncErrorHandler.alertPresenter = main - - window = UIWindow(frame: UIScreen.main.bounds) - window?.rootViewController = main - window?.makeKeyAndVisible() - - autoClear = AutoClear(worker: main) - let applicationState = application.applicationState - Task { - await autoClear?.clearDataIfEnabled(applicationState: .init(with: applicationState)) - await vpnWorkaround.installRedditSessionWorkaround() - } - } - - self.voiceSearchHelper.migrateSettingsFlagIfNecessary() - - // Task handler registration needs to happen before the end of `didFinishLaunching`, otherwise submitting a task can throw an exception. - // Having both in `didBecomeActive` can sometimes cause the exception when running on a physical device, so registration happens here. - AppConfigurationFetch.registerBackgroundRefreshTaskHandler() - - UNUserNotificationCenter.current().delegate = self - - window?.windowScene?.screenshotService?.delegate = self - ThemeManager.shared.updateUserInterfaceStyle(window: window) - - appIsLaunching = true - - // Temporary logic for rollout of Autofill as on by default for new installs only - if AppDependencyProvider.shared.appSettings.autofillIsNewInstallForOnByDefault == nil { - AppDependencyProvider.shared.appSettings.setAutofillIsNewInstallForOnByDefault() - } - - NewTabPageIntroMessageSetup().perform() - - widgetRefreshModel.beginObservingVPNStatus() - - AppDependencyProvider.shared.subscriptionManager.loadInitialData() +} - setUpAutofillPixelReporter() +protocol DDGApp { - if didCrashDuringCrashHandlersSetUp { - Pixel.fire(pixel: .crashOnCrashHandlersSetUp) - didCrashDuringCrashHandlersSetUp = false - } + var privacyProDataReporter: PrivacyProDataReporting? { get } + + func initialize() + func refreshRemoteMessages() - tipKitAppEventsHandler.appDidFinishLaunching() +} - return true - } +@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - private func makeWebsiteDataManager(fireproofing: Fireproofing, - dataStoreIDManager: DataStoreIDManaging = DataStoreIDManager.shared) -> WebsiteDataManaging { - return WebCacheManager(cookieStorage: MigratableCookieStorage(), - fireproofing: fireproofing, - dataStoreIDManager: dataStoreIDManager) + static let ShowKeyboardOnLaunchThreshold = TimeInterval(20) + struct ShortcutKey { + static let clipboard = "com.duckduckgo.mobile.ios.clipboard" + static let passwords = "com.duckduckgo.mobile.ios.passwords" + static let openVPNSettings = "com.duckduckgo.mobile.ios.vpn.open-settings" } - private func makeTextZoomCoordinator() -> TextZoomCoordinator { - let provider = AppDependencyProvider.shared - let storage = TextZoomStorage() + var window: UIWindow? - return TextZoomCoordinator(appSettings: provider.appSettings, - storage: storage, - featureFlagger: provider.featureFlagger) + var privacyProDataReporter: PrivacyProDataReporting? { + realDelegate.privacyProDataReporter } - private func makeSubscriptionCookieManager() -> SubscriptionCookieManaging { - let subscriptionCookieManager = SubscriptionCookieManager(subscriptionManager: AppDependencyProvider.shared.subscriptionManager, - currentCookieStore: { [weak self] in - guard self?.mainViewController?.tabManager.model.hasActiveTabs ?? false else { - // We shouldn't interact with WebKit's cookie store unless we have a WebView, - // eventually the subscription cookie will be refreshed on opening the first tab - return nil - } - - return WKHTTPCookieStoreWrapper(store: WKWebsiteDataStore.current().httpCookieStore) - }, eventMapping: SubscriptionCookieManageEventPixelMapping()) - - - let privacyConfigurationManager = ContentBlocking.shared.privacyConfigurationManager - - // Enable subscriptionCookieManager if feature flag is present - if privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) { - subscriptionCookieManager.enableSettingSubscriptionCookie() - } - - // Keep track of feature flag changes - subscriptionCookieManagerFeatureFlagCancellable = privacyConfigurationManager.updatesPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self, weak privacyConfigurationManager] in - guard let self, !self.appIsLaunching, let privacyConfigurationManager else { return } - - let isEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) - - Task { @MainActor [weak self] in - if isEnabled { - self?.subscriptionCookieManager.enableSettingSubscriptionCookie() - } else { - await self?.subscriptionCookieManager.disableSettingSubscriptionCookie() - } - } - } - - return subscriptionCookieManager + func forceOldAppDelegate() { + BoolFileMarker(name: .forceOldAppDelegate)?.mark() } - private func makeHistoryManager() -> HistoryManaging { - - let provider = AppDependencyProvider.shared - - switch HistoryManager.make(isAutocompleteEnabledByUser: provider.appSettings.autocomplete, - isRecentlyVisitedSitesEnabledByUser: provider.appSettings.recentlyVisitedSites, - privacyConfigManager: ContentBlocking.shared.privacyConfigurationManager, - tld: provider.storageCache.tld) { - - case .failure(let error): - Pixel.fire(pixel: .historyStoreLoadFailed, error: error) - if error.isDiskFull { - self.presentInsufficientDiskSpaceAlert() - } else { - self.presentPreemptiveCrashAlert() - } - return NullHistoryManager() - - case .success(let historyManager): - return historyManager - } - } + private let appBehavior: AppBehavior = { + BoolFileMarker(name: .forceOldAppDelegate)?.isPresent == true ? .old : .new + }() - private func prepareTabsModel(previewsSource: TabPreviewsSource = TabPreviewsSource(), - appSettings: AppSettings = AppDependencyProvider.shared.appSettings, - isDesktop: Bool = UIDevice.current.userInterfaceIdiom == .pad) -> TabsModel { - let isPadDevice = UIDevice.current.userInterfaceIdiom == .pad - let tabsModel: TabsModel - if AutoClearSettingsModel(settings: appSettings) != nil { - tabsModel = TabsModel(desktop: isPadDevice) - tabsModel.save() - previewsSource.removeAllPreviews() + private lazy var realDelegate: UIApplicationDelegate & DDGApp = { + if appBehavior == .old { + return OldAppDelegate(with: self) } else { - if let storedModel = TabsModel.get() { - // Save new model in case of migration - storedModel.save() - tabsModel = storedModel - } else { - tabsModel = TabsModel(desktop: isPadDevice) - } - } - return tabsModel - } - - private func presentPreemptiveCrashAlert() { - Task { @MainActor in - let alertController = CriticalAlerts.makePreemptiveCrashAlert() - window?.rootViewController?.present(alertController, animated: true, completion: nil) - } - } - - private func presentInsufficientDiskSpaceAlert() { - let alertController = CriticalAlerts.makeInsufficientDiskSpaceAlert() - window?.rootViewController?.present(alertController, animated: true, completion: nil) - } - - private func presentExpiredEntitlementAlert() { - let alertController = CriticalAlerts.makeExpiredEntitlementAlert { [weak self] in - self?.mainViewController?.segueToPrivacyPro() + return NewAppDelegate() } - window?.rootViewController?.present(alertController, animated: true) { [weak self] in - self?.tunnelDefaults.showEntitlementAlert = false - } - } - - private func presentExpiredEntitlementNotificationIfNeeded() { - let presenter = NetworkProtectionNotificationsPresenterTogglableDecorator( - settings: AppDependencyProvider.shared.vpnSettings, - defaults: .networkProtectionGroupDefaults, - wrappee: NetworkProtectionUNNotificationPresenter() - ) - presenter.showEntitlementNotification() - } - - private func cleanUpMacPromoExperiment2() { - UserDefaults.standard.removeObject(forKey: "com.duckduckgo.ios.macPromoMay23.exp2.cohort") - } - - private func cleanUpIncrementalRolloutPixelTest() { - UserDefaults.standard.removeObject(forKey: "network-protection.incremental-feature-flag-test.has-sent-pixel") - } + }() - private func clearTmp() { - let tmp = FileManager.default.temporaryDirectory - do { - try FileManager.default.removeItem(at: tmp) - } catch { - Logger.general.error("Failed to delete tmp dir") - } + override init() { + super.init() + realDelegate.initialize() } - private func reportAdAttribution() { - Task.detached(priority: .background) { - await AdAttributionPixelReporter.shared.reportAttributionIfNeeded() - } + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + realDelegate.application?(application, didFinishLaunchingWithOptions: launchOptions) ?? false } func applicationDidBecomeActive(_ application: UIApplication) { - guard !testing else { return } - - appStateMachine.handle(.activating(application)) - - defer { - if let didFinishLaunchingStartTime { - let launchTime = CFAbsoluteTimeGetCurrent() - didFinishLaunchingStartTime - Pixel.fire(pixel: .appDidBecomeActiveTime(time: Pixel.Event.BucketAggregation(number: launchTime)), - withAdditionalParameters: [PixelParameters.time: String(launchTime)]) - } - } - - StorageInconsistencyMonitor().didBecomeActive(isProtectedDataAvailable: application.isProtectedDataAvailable) - syncService.initializeIfNeeded() - syncDataProviders.setUpDatabaseCleanersIfNeeded(syncService: syncService) - - if !(overlayWindow?.rootViewController is AuthenticationViewController) { - removeOverlay() - } - - StatisticsLoader.shared.load { - StatisticsLoader.shared.refreshAppRetentionAtb() - self.fireAppLaunchPixel() - self.reportAdAttribution() - self.onboardingPixelReporter.fireEnqueuedPixelsIfNeeded() - } - - if appIsLaunching { - appIsLaunching = false - onApplicationLaunch(application) - } - - mainViewController?.showBars() - mainViewController?.didReturnFromBackground() - - if !privacyStore.authenticationEnabled { - showKeyboardOnLaunch() - } - - if AppConfigurationFetch.shouldScheduleRulesCompilationOnAppLaunch { - ContentBlocking.shared.contentBlockingManager.scheduleCompilation() - AppConfigurationFetch.shouldScheduleRulesCompilationOnAppLaunch = false - } - AppDependencyProvider.shared.configurationManager.loadPrivacyConfigFromDiskIfNeeded() - - AppConfigurationFetch().start { result in - self.sendAppLaunchPostback() - if case .assetsUpdated(let protectionsUpdated) = result, protectionsUpdated { - ContentBlocking.shared.contentBlockingManager.scheduleCompilation() - } - } - - syncService.scheduler.notifyAppLifecycleEvent() - - privacyProDataReporter.injectSyncService(syncService) - - fireFailedCompilationsPixelIfNeeded() - - widgetRefreshModel.refreshVPNWidget() - - if tunnelDefaults.showEntitlementAlert { - presentExpiredEntitlementAlert() - } - - presentExpiredEntitlementNotificationIfNeeded() - - Task { - await stopAndRemoveVPNIfNotAuthenticated() - await refreshShortcuts() - await vpnWorkaround.installRedditSessionWorkaround() - - if #available(iOS 17.0, *) { - await VPNSnoozeLiveActivityManager().endSnoozeActivityIfNecessary() - } - } - - AppDependencyProvider.shared.subscriptionManager.refreshCachedSubscriptionAndEntitlements { isSubscriptionActive in - if isSubscriptionActive { - DailyPixel.fire(pixel: .privacyProSubscriptionActive) - } - } - - Task { - await subscriptionCookieManager.refreshSubscriptionCookie() - } - - let importPasswordsStatusHandler = ImportPasswordsStatusHandler(syncService: syncService) - importPasswordsStatusHandler.checkSyncSuccessStatus() - - Task { - await privacyProDataReporter.saveWidgetAdded() - } - - AppDependencyProvider.shared.persistentPixel.sendQueuedPixels { _ in } - } - - 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(reason: .didBecomeActiveCheck) + realDelegate.applicationDidBecomeActive?(application) } func applicationWillResignActive(_ application: UIApplication) { - appStateMachine.handle(.suspending(application)) - Task { @MainActor in - await refreshShortcuts() - await vpnWorkaround.removeRedditSessionWorkaround() - } - } - - private func fireAppLaunchPixel() { - - WidgetCenter.shared.getCurrentConfigurations { result in - let paramKeys: [WidgetFamily: String] = [ - .systemSmall: PixelParameters.widgetSmall, - .systemMedium: PixelParameters.widgetMedium, - .systemLarge: PixelParameters.widgetLarge - ] - - switch result { - case .failure(let error): - Pixel.fire(pixel: .appLaunch, withAdditionalParameters: [ - PixelParameters.widgetError: "1", - PixelParameters.widgetErrorCode: "\((error as NSError).code)", - PixelParameters.widgetErrorDomain: (error as NSError).domain - ], includedParameters: [.appVersion, .atb]) - - case .success(let widgetInfo): - let params = widgetInfo.reduce([String: String]()) { - var result = $0 - if let key = paramKeys[$1.family] { - result[key] = "1" - } - return result - } - Pixel.fire(pixel: .appLaunch, withAdditionalParameters: params, includedParameters: [.appVersion, .atb]) - } - - } - } - - private func fireFailedCompilationsPixelIfNeeded() { - let store = FailedCompilationsStore() - if store.hasAnyFailures { - DailyPixel.fire(pixel: .compilationFailed, withAdditionalParameters: store.summary) { error in - guard error != nil else { return } - store.cleanup() - } - } - } - - private func shouldShowKeyboardOnLaunch() -> Bool { - guard let date = lastBackgroundDate else { return true } - return Date().timeIntervalSince(date) > AppDelegate.ShowKeyboardOnLaunchThreshold - } - - private func showKeyboardOnLaunch() { - guard KeyboardSettings().onAppLaunch && showKeyboardIfSettingOn && shouldShowKeyboardOnLaunch() else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.mainViewController?.enterSearch() - } - showKeyboardIfSettingOn = false - } - - private func onApplicationLaunch(_ application: UIApplication) { - Task { @MainActor in - await beginAuthentication() - initialiseBackgroundFetch(application) - applyAppearanceChanges() - refreshRemoteMessages() - } - } - - private func applyAppearanceChanges() { - UILabel.appearance(whenContainedInInstancesOf: [UIAlertController.self]).numberOfLines = 0 - } - - /// It's public in order to allow refreshing on demand via Debug menu. Otherwise it shouldn't be called from outside. - func refreshRemoteMessages() { - Task { - try? await remoteMessagingClient.fetchAndProcess(using: remoteMessagingClient.store) - } + realDelegate.applicationWillResignActive?(application) } func applicationWillEnterForeground(_ application: UIApplication) { - ThemeManager.shared.updateUserInterfaceStyle() - - Task { @MainActor in - await beginAuthentication() - await autoClear?.clearDataIfEnabledAndTimeExpired(applicationState: .active) - showKeyboardIfSettingOn = true - syncService.scheduler.resumeSyncQueue() - } + realDelegate.applicationWillEnterForeground?(application) } func applicationDidEnterBackground(_ application: UIApplication) { - appStateMachine.handle(.backgrounding(application)) - displayBlankSnapshotWindow() - autoClear?.startClearingTimer() - lastBackgroundDate = Date() - AppDependencyProvider.shared.autofillLoginSession.endSession() - suspendSync() - syncDataProviders.bookmarksAdapter.cancelFaviconsFetching(application) - privacyProDataReporter.saveApplicationLastSessionEnded() - resetAppStartTime() - } - - private func resetAppStartTime() { - didFinishLaunchingStartTime = nil - mainViewController?.appDidFinishLaunchingStartTime = nil - } - - private func suspendSync() { - if syncService.isSyncInProgress { - Logger.sync.debug("Sync is in progress. Starting background task to allow it to gracefully complete.") - - var taskID: UIBackgroundTaskIdentifier! - taskID = UIApplication.shared.beginBackgroundTask(withName: "Cancelled Sync Completion Task") { - Logger.sync.debug("Forcing background task completion") - UIApplication.shared.endBackgroundTask(taskID) - } - syncDidFinishCancellable?.cancel() - syncDidFinishCancellable = syncService.isSyncInProgressPublisher.filter { !$0 } - .prefix(1) - .receive(on: DispatchQueue.main) - .sink { _ in - Logger.sync.debug("Ending background task") - UIApplication.shared.endBackgroundTask(taskID) - } - } - - syncService.scheduler.cancelSyncAndSuspendSyncQueue() + realDelegate.applicationDidEnterBackground?(application) } func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { - handleShortCutItem(shortcutItem) + realDelegate.application?(application, performActionFor: shortcutItem, completionHandler: completionHandler) } func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - Logger.sync.debug("App launched with url \(url.absoluteString)") - appStateMachine.handle(.openURL(url)) - - // If showing the onboarding intro ignore deeplinks - guard mainViewController?.needsToShowOnboardingIntro() == false else { - return false - } - - if handleEmailSignUpDeepLink(url) { - return true - } - - NotificationCenter.default.post(name: AutofillLoginListAuthenticator.Notifications.invalidateContext, object: nil) - - // The openVPN action handles the navigation stack on its own and does not need it to be cleared - if url != AppDeepLinkSchemes.openVPN.url { - mainViewController?.clearNavigationStack() - } - - Task { @MainActor in - // Autoclear should have happened by now - showKeyboardIfSettingOn = false - - if !handleAppDeepLink(app, mainViewController, url) { - mainViewController?.loadUrlInNewTab(url, reuseExisting: true, inheritedAttribution: nil, fromExternalLink: true) - } - } - - return true + realDelegate.application?(app, open: url, options: options) ?? false } func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { @@ -932,337 +120,11 @@ import os.log return true } - // MARK: private - - private func sendAppLaunchPostback() { - // Attribution support - let privacyConfigurationManager = ContentBlocking.shared.privacyConfigurationManager - if privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .marketplaceAdPostback) { - marketplaceAdPostbackManager.sendAppLaunchPostback() - } - } - - private func cleanUpATBAndAssignVariant(variantManager: VariantManager, daxDialogs: DaxDialogs) { - let historyMessageManager = HistoryMessageManager() - - AtbAndVariantCleanup.cleanup() - variantManager.assignVariantIfNeeded { _ in - // MARK: perform first time launch logic here - // If it's running UI Tests check if the onboarding should be in a completed state. - if launchOptionsHandler.isUITesting && launchOptionsHandler.isOnboardingCompleted { - daxDialogs.dismiss() - } else { - daxDialogs.primeForUse() - } - - // New users don't see the message - historyMessageManager.dismiss() - - // Setup storage for marketplace postback - marketplaceAdPostbackManager.updateReturningUserValue() - } - } - - private func initialiseBackgroundFetch(_ application: UIApplication) { - guard UIApplication.shared.backgroundRefreshStatus == .available else { - return - } - - // BackgroundTasks will automatically replace an existing task in the queue if one with the same identifier is queued, so we should only - // schedule a task if there are none pending in order to avoid the config task getting perpetually replaced. - BGTaskScheduler.shared.getPendingTaskRequests { tasks in - let hasConfigurationTask = tasks.contains { $0.identifier == AppConfigurationFetch.Constants.backgroundProcessingTaskIdentifier } - if !hasConfigurationTask { - AppConfigurationFetch.scheduleBackgroundRefreshTask() - } - - let hasRemoteMessageFetchTask = tasks.contains { $0.identifier == RemoteMessagingClient.Constants.backgroundRefreshTaskIdentifier } - if !hasRemoteMessageFetchTask { - RemoteMessagingClient.scheduleBackgroundRefreshTask() - } - } - } - - private func displayAuthenticationWindow() { - guard overlayWindow == nil, let frame = window?.frame else { return } - overlayWindow = UIWindow(frame: frame) - overlayWindow?.windowLevel = UIWindow.Level.alert - overlayWindow?.rootViewController = AuthenticationViewController.loadFromStoryboard() - overlayWindow?.makeKeyAndVisible() - window?.isHidden = true - } - - private func displayBlankSnapshotWindow() { - guard overlayWindow == nil, let frame = window?.frame else { return } - guard autoClear?.isClearingEnabled ?? false || privacyStore.authenticationEnabled else { return } - - overlayWindow = UIWindow(frame: frame) - overlayWindow?.windowLevel = UIWindow.Level.alert - - let overlay = BlankSnapshotViewController(appSettings: AppDependencyProvider.shared.appSettings, voiceSearchHelper: voiceSearchHelper) - overlay.delegate = self - - overlayWindow?.rootViewController = overlay - overlayWindow?.makeKeyAndVisible() - window?.isHidden = true - } - - private func beginAuthentication() async { - - guard privacyStore.authenticationEnabled else { return } - - removeOverlay() - displayAuthenticationWindow() - - guard let controller = overlayWindow?.rootViewController as? AuthenticationViewController else { - removeOverlay() - return - } - - await controller.beginAuthentication { [weak self] in - self?.removeOverlay() - self?.showKeyboardOnLaunch() - } - } - - private func tryToObtainOverlayWindow() { - for window in UIApplication.shared.foregroundSceneWindows where window.rootViewController is BlankSnapshotViewController { - overlayWindow = window - return - } - } - - private func removeOverlay() { - if overlayWindow == nil { - tryToObtainOverlayWindow() - } - - if let overlay = overlayWindow { - overlay.isHidden = true - overlayWindow = nil - window?.makeKeyAndVisible() - } - } - - private func handleShortCutItem(_ shortcutItem: UIApplicationShortcutItem) { - Logger.general.debug("Handling shortcut item: \(shortcutItem.type)") - - Task { @MainActor in - - if appIsLaunching { - await autoClear?.clearDataIfEnabled() - } else { - await autoClear?.clearDataIfEnabledAndTimeExpired(applicationState: .active) - } - - if shortcutItem.type == ShortcutKey.clipboard, let query = UIPasteboard.general.string { - mainViewController?.clearNavigationStack() - mainViewController?.loadQueryInNewTab(query) - return - } - - if shortcutItem.type == ShortcutKey.passwords { - mainViewController?.clearNavigationStack() - // Give the `clearNavigationStack` call time to complete. - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) { [weak self] in - self?.mainViewController?.launchAutofillLogins(openSearch: true, source: .appIconShortcut) - } - Pixel.fire(pixel: .autofillLoginsLaunchAppShortcut) - return - } - - if shortcutItem.type == ShortcutKey.openVPNSettings { - presentNetworkProtectionStatusSettingsModal() - } - - } - } - - private func removeEmailWaitlistState() { - EmailWaitlist.removeEmailState() - - let autofillStorage = EmailKeychainManager() - try? autofillStorage.deleteWaitlistState() - - // Remove the authentication state if this is a fresh install. - if !Database.shared.isDatabaseFileInitialized { - try? autofillStorage.deleteAuthenticationState() - } - } - - private func handleEmailSignUpDeepLink(_ url: URL) -> Bool { - guard url.absoluteString.starts(with: URL.emailProtection.absoluteString), - let navViewController = mainViewController?.presentedViewController as? UINavigationController, - let emailSignUpViewController = navViewController.topViewController as? EmailSignupViewController else { - return false - } - emailSignUpViewController.loadUrl(url) - return true - } - - private var mainViewController: MainViewController? { - return window?.rootViewController as? MainViewController - } - - private func setUpAutofillPixelReporter() { - autofillPixelReporter = AutofillPixelReporter( - userDefaults: .standard, - autofillEnabled: AppDependencyProvider.shared.appSettings.autofillCredentialsEnabled, - eventMapping: EventMapping {[weak self] event, _, params, _ in - switch event { - case .autofillActiveUser: - Pixel.fire(pixel: .autofillActiveUser) - case .autofillEnabledUser: - Pixel.fire(pixel: .autofillEnabledUser) - case .autofillOnboardedUser: - Pixel.fire(pixel: .autofillOnboardedUser) - case .autofillToggledOn: - Pixel.fire(pixel: .autofillToggledOn, withAdditionalParameters: params ?? [:]) - if let autofillExtensionToggled = self?.autofillUsageMonitor.autofillExtensionEnabled { - Pixel.fire(pixel: autofillExtensionToggled ? .autofillExtensionToggledOn : .autofillExtensionToggledOff, - withAdditionalParameters: params ?? [:]) - } - case .autofillToggledOff: - Pixel.fire(pixel: .autofillToggledOff, withAdditionalParameters: params ?? [:]) - if let autofillExtensionToggled = self?.autofillUsageMonitor.autofillExtensionEnabled { - Pixel.fire(pixel: autofillExtensionToggled ? .autofillExtensionToggledOn : .autofillExtensionToggledOff, - withAdditionalParameters: params ?? [:]) - } - case .autofillLoginsStacked: - Pixel.fire(pixel: .autofillLoginsStacked, withAdditionalParameters: params ?? [:]) - default: - break - } - }, - installDate: StatisticsUserDefaults().installDate ?? Date()) - - _ = NotificationCenter.default.addObserver(forName: AppUserDefaults.Notifications.autofillEnabledChange, - object: nil, - queue: nil) { [weak self] _ in - self?.autofillPixelReporter?.updateAutofillEnabledStatus(AppDependencyProvider.shared.appSettings.autofillCredentialsEnabled) - } - } - - @MainActor - func refreshShortcuts() async { - guard AppDependencyProvider.shared.vpnFeatureVisibility.shouldShowVPNShortcut() else { - UIApplication.shared.shortcutItems = nil - return - } - - if case .success(true) = await accountManager.hasEntitlement(forProductName: .networkProtection, cachePolicy: .returnCacheDataDontLoad) { - let items = [ - UIApplicationShortcutItem(type: ShortcutKey.openVPNSettings, - localizedTitle: UserText.netPOpenVPNQuickAction, - localizedSubtitle: nil, - icon: UIApplicationShortcutIcon(templateImageName: "VPN-16"), - userInfo: nil) - ] - - UIApplication.shared.shortcutItems = items - } else { - UIApplication.shared.shortcutItems = nil - } - } -} - -extension AppDelegate: BlankSnapshotViewRecoveringDelegate { - - func recoverFromPresenting(controller: BlankSnapshotViewController) { - if overlayWindow == nil { - tryToObtainOverlayWindow() - } - - overlayWindow?.isHidden = true - overlayWindow = nil - window?.makeKeyAndVisible() - } - -} - -extension AppDelegate: UIScreenshotServiceDelegate { - func screenshotService(_ screenshotService: UIScreenshotService, - generatePDFRepresentationWithCompletion completionHandler: @escaping (Data?, Int, CGRect) -> Void) { - guard let webView = mainViewController?.currentTab?.webView else { - completionHandler(nil, 0, .zero) - return - } - - let zoomScale = webView.scrollView.zoomScale - - // The PDF's coordinate space has its origin at the bottom left, so the view's origin.y needs to be converted - let visibleBounds = CGRect( - x: webView.scrollView.contentOffset.x / zoomScale, - y: (webView.scrollView.contentSize.height - webView.scrollView.contentOffset.y - webView.bounds.height) / zoomScale, - width: webView.bounds.width / zoomScale, - height: webView.bounds.height / zoomScale - ) - - webView.createPDF { result in - let data = try? result.get() - completionHandler(data, 0, visibleBounds) - } - } -} - -extension AppDelegate: UNUserNotificationCenterDelegate { - - func userNotificationCenter(_ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - completionHandler(.banner) - } - - func userNotificationCenter(_ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void) { - if response.actionIdentifier == UNNotificationDefaultActionIdentifier { - let identifier = response.notification.request.identifier - - if NetworkProtectionNotificationIdentifier(rawValue: identifier) != nil { - presentNetworkProtectionStatusSettingsModal() - } - } - - completionHandler() - } - - func presentNetworkProtectionStatusSettingsModal() { - Task { - if case .success(let hasEntitlements) = await accountManager.hasEntitlement(forProductName: .networkProtection), hasEntitlements { - (window?.rootViewController as? MainViewController)?.segueToVPN() - } else { - (window?.rootViewController as? MainViewController)?.segueToPrivacyPro() - } - } + /// It's public in order to allow refreshing on demand via Debug menu. Otherwise it shouldn't be called from outside. + func refreshRemoteMessages() { + realDelegate.refreshRemoteMessages() } - private func presentSettings(with viewController: UIViewController) { - guard let window = window, let rootViewController = window.rootViewController as? MainViewController else { return } - - if let navigationController = rootViewController.presentedViewController as? UINavigationController { - if let lastViewController = navigationController.viewControllers.last, lastViewController.isKind(of: type(of: viewController)) { - // Avoid presenting dismissing and re-presenting the view controller if it's already visible: - return - } else { - // Otherwise, replace existing view controllers with the presented one: - navigationController.popToRootViewController(animated: false) - navigationController.pushViewController(viewController, animated: false) - return - } - } - - // If the previous checks failed, make sure the nav stack is reset and present the view controller from scratch: - rootViewController.clearNavigationStack() - - // Give the `clearNavigationStack` call time to complete. - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) { - rootViewController.segueToSettings() - let navigationController = rootViewController.presentedViewController as? UINavigationController - navigationController?.popToRootViewController(animated: false) - navigationController?.pushViewController(viewController, animated: false) - } - } } extension DataStoreWarmup.ApplicationState { @@ -1281,7 +143,7 @@ extension DataStoreWarmup.ApplicationState { } } -private extension Error { +extension Error { var isDiskFull: Bool { let nsError = self as NSError @@ -1297,3 +159,9 @@ private extension Error { } } + +private extension BoolFileMarker.Name { + + static let forceOldAppDelegate = BoolFileMarker.Name(rawValue: "force-old-app-delegate") + +} diff --git a/DuckDuckGo/AppDependencies.swift b/DuckDuckGo/AppDependencies.swift new file mode 100644 index 0000000000..4c3cb5df0e --- /dev/null +++ b/DuckDuckGo/AppDependencies.swift @@ -0,0 +1,58 @@ +// +// AppDependencies.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 Subscription +import UIKit +import Core +import DDGSync +import Combine +import BrowserServicesKit + +struct AppDependencies { + + let accountManager: AccountManager + let vpnWorkaround: VPNRedditSessionWorkaround + let vpnFeatureVisibility: DefaultNetworkProtectionVisibility + + let appSettings: AppSettings + let privacyStore: PrivacyUserDefaults + + let uiService: UIService + let mainViewController: MainViewController + + let voiceSearchHelper: VoiceSearchHelper + let autoClear: AutoClear + let autofillLoginSession: AutofillLoginSession + let marketplaceAdPostbackManager: MarketplaceAdPostbackManaging + let syncService: DDGSync + let syncDataProviders: SyncDataProviders + let isSyncInProgressCancellable: AnyCancellable + let privacyProDataReporter: PrivacyProDataReporting + let remoteMessagingClient: RemoteMessagingClient + + let subscriptionService: SubscriptionService + + let onboardingPixelReporter: OnboardingPixelReporter + let widgetRefreshModel: NetworkProtectionWidgetRefreshModel + let autofillPixelReporter: AutofillPixelReporter + let crashReportUploaderOnboarding: CrashCollectionOnboarding + + var syncDidFinishCancellable: AnyCancellable? + +} diff --git a/DuckDuckGo/AppLifecycle/AppStateMachine.swift b/DuckDuckGo/AppLifecycle/AppStateMachine.swift index 53fb3b1bf0..585f9bfb7c 100644 --- a/DuckDuckGo/AppLifecycle/AppStateMachine.swift +++ b/DuckDuckGo/AppLifecycle/AppStateMachine.swift @@ -21,27 +21,30 @@ import UIKit enum AppEvent { - case launching(UIApplication, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) - case activating(UIApplication) - case backgrounding(UIApplication) - case suspending(UIApplication) + case launching(UIApplication, isTesting: Bool) + case activating + case backgrounding + case suspending case openURL(URL) + case handleShortcutItem(UIApplicationShortcutItem) } protocol AppState { - func apply(event: AppEvent) -> any AppState + mutating func apply(event: AppEvent) -> any AppState } protocol AppEventHandler { + @MainActor func handle(_ event: AppEvent) } +@MainActor final class AppStateMachine: AppEventHandler { private(set) var currentState: any AppState = Init() diff --git a/DuckDuckGo/AppLifecycle/AppStateTransitions.swift b/DuckDuckGo/AppLifecycle/AppStateTransitions.swift index e7a5df5fbd..1d6fe1e89a 100644 --- a/DuckDuckGo/AppLifecycle/AppStateTransitions.swift +++ b/DuckDuckGo/AppLifecycle/AppStateTransitions.swift @@ -24,8 +24,11 @@ extension Init { func apply(event: AppEvent) -> any AppState { switch event { - case .launching(let application, let launchOptions): - return Launched(application: application, launchOptions: launchOptions) + case .launching(let application, let isTesting): + if isTesting { + return Testing(application: application) + } + return Launched(stateContext: makeStateContext(application: application)) default: return handleUnexpectedEvent(event) } @@ -35,14 +38,18 @@ extension Init { extension Launched { - func apply(event: AppEvent) -> any AppState { + mutating func apply(event: AppEvent) -> any AppState { switch event { - case .activating(let application): - return Active(application: application) - case .openURL: + case .activating: + return Active(stateContext: makeStateContext()) + case .openURL(let url): + urlToOpen = url + return self + case .handleShortcutItem(let shortcutItem): + shortcutItemToHandle = shortcutItem return self case .backgrounding: - return InactiveBackground() + return Background(stateContext: makeStateContext()) case .launching, .suspending: return handleUnexpectedEvent(event) } @@ -54,9 +61,13 @@ extension Active { func apply(event: AppEvent) -> any AppState { switch event { - case .suspending(let application): - return Inactive(application: application) - case .openURL: + case .suspending: + return Inactive(stateContext: makeStateContext()) + case .openURL(let url): + openURL(url) + return self + case .handleShortcutItem(let shortcutItem): + handleShortcutItem(shortcutItem) return self case .launching, .activating, .backgrounding: return handleUnexpectedEvent(event) @@ -67,15 +78,16 @@ extension Active { extension Inactive { - func apply(event: AppEvent) -> any AppState { + mutating func apply(event: AppEvent) -> any AppState { switch event { - case .backgrounding(let application): - return Background(application: application) - case .activating(let application): - return Active(application: application) - case .openURL: + case .backgrounding: + return Background(stateContext: makeStateContext()) + case .activating: + return Active(stateContext: makeStateContext()) + case .openURL(let url): + urlToOpen = url return self - case .launching, .suspending: + case .launching, .suspending, .handleShortcutItem: return handleUnexpectedEvent(event) } } @@ -84,14 +96,19 @@ extension Inactive { extension Background { - func apply(event: AppEvent) -> any AppState { + mutating func apply(event: AppEvent) -> any AppState { switch event { - case .activating(let application): - return Active(application: application) - case .openURL: + case .activating: + return Active(stateContext: makeStateContext()) + case .openURL(let url): + urlToOpen = url return self case .backgrounding: - return DoubleBackground() + run() + return self + case .handleShortcutItem(let shortcutItem): + shortcutItemToHandle = shortcutItem + return self case .launching, .suspending: return handleUnexpectedEvent(event) } @@ -99,40 +116,9 @@ extension Background { } -extension DoubleBackground { - - func apply(event: AppEvent) -> any AppState { - // report event so we know what events can be called at this moment, but do not let SM be stuck in this state just not to be flooded with these events - _ = handleUnexpectedEvent(event) - - switch event { - case .activating(let application): - return Active(application: application) - case .suspending(let application): - return Inactive(application: application) - case .launching, .backgrounding, .openURL: - return self - } - - } - -} - -extension InactiveBackground { +extension Testing { - func apply(event: AppEvent) -> any AppState { - // report event so we know what events can be called at this moment, but do not let SM be stuck in this state just not to be flooded with these events - _ = handleUnexpectedEvent(event) - - switch event { - case .activating(let application): - return Active(application: application) - case .suspending(let application): - return Inactive(application: application) - case .launching, .backgrounding, .openURL: - return self - } - } + func apply(event: AppEvent) -> any AppState { self } } @@ -145,6 +131,7 @@ extension AppEvent { case .backgrounding: return "backgrounding" case .suspending: return "suspending" case .openURL: return "openURL" + case .handleShortcutItem: return "handleShortcutItem" } } diff --git a/DuckDuckGo/AppLifecycle/AppStates/Active.swift b/DuckDuckGo/AppLifecycle/AppStates/Active.swift index df99c36d50..42afc268c4 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Active.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/Active.swift @@ -17,12 +17,500 @@ // limitations under the License. // +import Foundation import UIKit +import BrowserServicesKit +import Core +import WidgetKit +import BackgroundTasks +import Subscription +import NetworkProtection struct Active: AppState { - init(application: UIApplication) { + let application: UIApplication + let appDependencies: AppDependencies + private let privacyConfigurationManager = ContentBlocking.shared.privacyConfigurationManager + private let tunnelDefaults = UserDefaults.networkProtectionGroupDefaults + + private var window: UIWindow { + appDependencies.uiService.window + } + + private var mainViewController: MainViewController { + appDependencies.mainViewController + } + + // MARK: handle one-time (after launch) logic here + init(stateContext: Launched.StateContext) { + application = stateContext.application + appDependencies = stateContext.appDependencies + + defer { + let launchTime = CFAbsoluteTimeGetCurrent() - stateContext.didFinishLaunchingStartTime + Pixel.fire(pixel: .appDidBecomeActiveTime(time: Pixel.Event.BucketAggregation(number: launchTime)), + withAdditionalParameters: [PixelParameters.time: String(launchTime)]) + } + + // Keep track of feature flag changes + let subscriptionCookieManager = appDependencies.subscriptionService.subscriptionCookieManager + appDependencies.subscriptionService.onPrivacyConfigurationUpdate = { [privacyConfigurationManager] in + let isEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) + + Task { @MainActor in + if isEnabled { + subscriptionCookieManager.enableSettingSubscriptionCookie() + } else { + await subscriptionCookieManager.disableSettingSubscriptionCookie() + } + } + } + + // onApplicationLaunch code + Task { @MainActor [self] in + await beginAuthentication() + initialiseBackgroundFetch(application) + applyAppearanceChanges() + refreshRemoteMessages(remoteMessagingClient: appDependencies.remoteMessagingClient) + } + + if let url = stateContext.urlToOpen { + openURL(url) + } else if let shortcutItemToHandle = stateContext.shortcutItemToHandle { + handleShortcutItem(shortcutItemToHandle, appIsLaunching: true) + } + + activateApp() + } + + // MARK: handle applicationWillEnterForeground(_:) logic here + init(stateContext: Background.StateContext) { + application = stateContext.application + appDependencies = stateContext.appDependencies + + ThemeManager.shared.updateUserInterfaceStyle() + + let uiService = appDependencies.uiService + let syncService = appDependencies.syncService + let autoClear = appDependencies.autoClear + Task { @MainActor [self] in + await beginAuthentication(lastBackgroundDate: stateContext.lastBackgroundDate) + await autoClear.clearDataIfEnabledAndTimeExpired(applicationState: .active) + uiService.showKeyboardIfSettingOn = true + syncService.scheduler.resumeSyncQueue() + } + + if let url = stateContext.urlToOpen { + openURL(url) + } else if let shortcutItemToHandle = stateContext.shortcutItemToHandle { + handleShortcutItem(shortcutItemToHandle, appIsLaunching: false) + } + + activateApp() + } + + init(stateContext: Inactive.StateContext) { + application = stateContext.application + appDependencies = stateContext.appDependencies + + activateApp() + } + + // MARK: handle applicationDidBecomeActive(_:) logic here + private func activateApp(isTesting: Bool = false) { + StorageInconsistencyMonitor().didBecomeActive(isProtectedDataAvailable: application.isProtectedDataAvailable) + appDependencies.syncService.initializeIfNeeded() + appDependencies.syncDataProviders.setUpDatabaseCleanersIfNeeded(syncService: appDependencies.syncService) + + if !(appDependencies.uiService.overlayWindow?.rootViewController is AuthenticationViewController) { + appDependencies.uiService.removeOverlay() + } + + StatisticsLoader.shared.load { + StatisticsLoader.shared.refreshAppRetentionAtb() + self.fireAppLaunchPixel() + self.reportAdAttribution() + self.appDependencies.onboardingPixelReporter.fireEnqueuedPixelsIfNeeded() + } + + mainViewController.showBars() + mainViewController.didReturnFromBackground() + + if !appDependencies.privacyStore.authenticationEnabled { + showKeyboardOnLaunch() + } + + if AppConfigurationFetch.shouldScheduleRulesCompilationOnAppLaunch { + ContentBlocking.shared.contentBlockingManager.scheduleCompilation() + AppConfigurationFetch.shouldScheduleRulesCompilationOnAppLaunch = false + } + AppDependencyProvider.shared.configurationManager.loadPrivacyConfigFromDiskIfNeeded() + + AppConfigurationFetch().start { result in + self.sendAppLaunchPostback(marketplaceAdPostbackManager: appDependencies.marketplaceAdPostbackManager) + if case .assetsUpdated(let protectionsUpdated) = result, protectionsUpdated { + ContentBlocking.shared.contentBlockingManager.scheduleCompilation() + } + } + + appDependencies.syncService.scheduler.notifyAppLifecycleEvent() + + appDependencies.privacyProDataReporter.injectSyncService(appDependencies.syncService) + + fireFailedCompilationsPixelIfNeeded() + + appDependencies.widgetRefreshModel.refreshVPNWidget() + + if tunnelDefaults.showEntitlementAlert { + presentExpiredEntitlementAlert() + } + + presentExpiredEntitlementNotificationIfNeeded() + + Task { + await stopAndRemoveVPNIfNotAuthenticated() + await application.refreshVPNShortcuts(vpnFeatureVisibility: appDependencies.vpnFeatureVisibility, + accountManager: appDependencies.accountManager) + await appDependencies.vpnWorkaround.installRedditSessionWorkaround() + + if #available(iOS 17.0, *) { + await VPNSnoozeLiveActivityManager().endSnoozeActivityIfNecessary() + } + } + + AppDependencyProvider.shared.subscriptionManager.refreshCachedSubscriptionAndEntitlements { isSubscriptionActive in + if isSubscriptionActive { + DailyPixel.fire(pixel: .privacyProSubscriptionActive) + } + } + + Task { + await appDependencies.subscriptionService.subscriptionCookieManager.refreshSubscriptionCookie() + } + + let importPasswordsStatusHandler = ImportPasswordsStatusHandler(syncService: appDependencies.syncService) + importPasswordsStatusHandler.checkSyncSuccessStatus() + + Task { + await appDependencies.privacyProDataReporter.saveWidgetAdded() + } + + AppDependencyProvider.shared.persistentPixel.sendQueuedPixels { _ in } + } + + // MARK: handle application(_:open:options:) logic here + func openURL(_ url: URL) { + Logger.sync.debug("App launched with url \(url.absoluteString)") + // If showing the onboarding intro ignore deeplinks + guard mainViewController.needsToShowOnboardingIntro() == false else { + return + } + + if handleEmailSignUpDeepLink(url) { + return + } + + NotificationCenter.default.post(name: AutofillLoginListAuthenticator.Notifications.invalidateContext, object: nil) + + // The openVPN action handles the navigation stack on its own and does not need it to be cleared + if url != AppDeepLinkSchemes.openVPN.url { + mainViewController.clearNavigationStack() + } + + Task { @MainActor in + // Autoclear should have happened by now + appDependencies.uiService.showKeyboardIfSettingOn = false + + if !handleAppDeepLink(application, mainViewController, url) { + mainViewController.loadUrlInNewTab(url, reuseExisting: true, inheritedAttribution: nil, fromExternalLink: true) + } + } + } + + @MainActor + private func beginAuthentication(lastBackgroundDate: Date? = nil) async { + guard appDependencies.privacyStore.authenticationEnabled else { return } + + let uiService = appDependencies.uiService + uiService.removeOverlay() + uiService.displayAuthenticationWindow() + + guard let controller = uiService.overlayWindow?.rootViewController as? AuthenticationViewController else { + uiService.removeOverlay() + return + } + + await controller.beginAuthentication { + uiService.removeOverlay() + showKeyboardOnLaunch(lastBackgroundDate: lastBackgroundDate) + } + } + + private func showKeyboardOnLaunch(lastBackgroundDate: Date? = nil) { + guard KeyboardSettings().onAppLaunch && appDependencies.uiService.showKeyboardIfSettingOn && shouldShowKeyboardOnLaunch(lastBackgroundDate: lastBackgroundDate) else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.mainViewController.enterSearch() + } + appDependencies.uiService.showKeyboardIfSettingOn = false + } + + private func shouldShowKeyboardOnLaunch(lastBackgroundDate: Date? = nil) -> Bool { + guard let lastBackgroundDate else { return true } + return Date().timeIntervalSince(lastBackgroundDate) > AppDelegate.ShowKeyboardOnLaunchThreshold + } + + private func fireAppLaunchPixel() { + + WidgetCenter.shared.getCurrentConfigurations { result in + let paramKeys: [WidgetFamily: String] = [ + .systemSmall: PixelParameters.widgetSmall, + .systemMedium: PixelParameters.widgetMedium, + .systemLarge: PixelParameters.widgetLarge + ] + + switch result { + case .failure(let error): + Pixel.fire(pixel: .appLaunch, withAdditionalParameters: [ + PixelParameters.widgetError: "1", + PixelParameters.widgetErrorCode: "\((error as NSError).code)", + PixelParameters.widgetErrorDomain: (error as NSError).domain + ], includedParameters: [.appVersion, .atb]) + + case .success(let widgetInfo): + let params = widgetInfo.reduce([String: String]()) { + var result = $0 + if let key = paramKeys[$1.family] { + result[key] = "1" + } + return result + } + Pixel.fire(pixel: .appLaunch, withAdditionalParameters: params, includedParameters: [.appVersion, .atb]) + } + + } + } + + private func sendAppLaunchPostback(marketplaceAdPostbackManager: MarketplaceAdPostbackManaging) { + // Attribution support + let privacyConfigurationManager = ContentBlocking.shared.privacyConfigurationManager + if privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .marketplaceAdPostback) { + marketplaceAdPostbackManager.sendAppLaunchPostback() + } + } + + private func reportAdAttribution() { + Task.detached(priority: .background) { + await AdAttributionPixelReporter.shared.reportAttributionIfNeeded() + } + } + + private func initialiseBackgroundFetch(_ application: UIApplication) { + guard UIApplication.shared.backgroundRefreshStatus == .available else { + return + } + + // BackgroundTasks will automatically replace an existing task in the queue if one with the same identifier is queued, so we should only + // schedule a task if there are none pending in order to avoid the config task getting perpetually replaced. + BGTaskScheduler.shared.getPendingTaskRequests { tasks in + let hasConfigurationTask = tasks.contains { $0.identifier == AppConfigurationFetch.Constants.backgroundProcessingTaskIdentifier } + if !hasConfigurationTask { + AppConfigurationFetch.scheduleBackgroundRefreshTask() + } + + let hasRemoteMessageFetchTask = tasks.contains { $0.identifier == RemoteMessagingClient.Constants.backgroundRefreshTaskIdentifier } + if !hasRemoteMessageFetchTask { + RemoteMessagingClient.scheduleBackgroundRefreshTask() + } + } + } + + private func applyAppearanceChanges() { + UILabel.appearance(whenContainedInInstancesOf: [UIAlertController.self]).numberOfLines = 0 + } + + /// It's public in order to allow refreshing on demand via Debug menu. Otherwise it shouldn't be called from outside. + func refreshRemoteMessages(remoteMessagingClient: RemoteMessagingClient) { + Task { + try? await remoteMessagingClient.fetchAndProcess(using: remoteMessagingClient.store) + } + } + + private func presentExpiredEntitlementAlert() { + let alertController = CriticalAlerts.makeExpiredEntitlementAlert { [weak mainViewController] in + mainViewController?.segueToPrivacyPro() + } + window.rootViewController?.present(alertController, animated: true) { [weak tunnelDefaults] in + tunnelDefaults?.showEntitlementAlert = false + } + } + + private func handleEmailSignUpDeepLink(_ url: URL) -> Bool { + guard url.absoluteString.starts(with: URL.emailProtection.absoluteString), + let navViewController = mainViewController.presentedViewController as? UINavigationController, + let emailSignUpViewController = navViewController.topViewController as? EmailSignupViewController else { + return false + } + emailSignUpViewController.loadUrl(url) + return true + } + + private func fireFailedCompilationsPixelIfNeeded() { + let store = FailedCompilationsStore() + if store.hasAnyFailures { + DailyPixel.fire(pixel: .compilationFailed, withAdditionalParameters: store.summary) { error in + guard error != nil else { return } + store.cleanup() + } + } + } + + private func stopAndRemoveVPNIfNotAuthenticated() async { + // Only remove the VPN if the user is not authenticated, and it's installed: + guard !appDependencies.accountManager.isUserAuthenticated, await AppDependencyProvider.shared.networkProtectionTunnelController.isInstalled else { + return + } + + await AppDependencyProvider.shared.networkProtectionTunnelController.stop() + await AppDependencyProvider.shared.networkProtectionTunnelController.removeVPN(reason: .didBecomeActiveCheck) + } + + private func presentExpiredEntitlementNotificationIfNeeded() { + let presenter = NetworkProtectionNotificationsPresenterTogglableDecorator( + settings: AppDependencyProvider.shared.vpnSettings, + defaults: .networkProtectionGroupDefaults, + wrappee: NetworkProtectionUNNotificationPresenter() + ) + presenter.showEntitlementNotification() + } + + + @MainActor + func handleAppDeepLink(_ app: UIApplication, _ mainViewController: MainViewController?, _ url: URL) -> Bool { + guard let mainViewController else { return false } + + switch AppDeepLinkSchemes.fromURL(url) { + + case .newSearch: + mainViewController.newTab(reuseExisting: true) + mainViewController.enterSearch() + + case .favorites: + mainViewController.newTab(reuseExisting: true, allowingKeyboard: false) + + case .quickLink: + let query = AppDeepLinkSchemes.query(fromQuickLink: url) + mainViewController.loadQueryInNewTab(query, reuseExisting: true) + + case .addFavorite: + mainViewController.startAddFavoriteFlow() + + case .fireButton: + mainViewController.forgetAllWithAnimation() + + case .voiceSearch: + mainViewController.onVoiceSearchPressed() + + case .newEmail: + mainViewController.newEmailAddress() + + case .openVPN: + presentNetworkProtectionStatusSettingsModal() + + case .openPasswords: + var source: AutofillSettingsSource = .homeScreenWidget + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems, + queryItems.first(where: { $0.name == "ls" }) != nil { + Pixel.fire(pixel: .autofillLoginsLaunchWidgetLock) + source = .lockScreenWidget + } else { + Pixel.fire(pixel: .autofillLoginsLaunchWidgetHome) + } + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) { + mainViewController.launchAutofillLogins(openSearch: true, source: source) + } + + default: + guard app.applicationState == .active, + let currentTab = mainViewController.currentTab else { + return false + } + + // If app is in active state, treat this navigation as something initiated form the context of the current tab. + mainViewController.tab(currentTab, + didRequestNewTabForUrl: url, + openedByPage: true, + inheritingAttribution: nil) + } + + return true + } + + @MainActor + func presentNetworkProtectionStatusSettingsModal() { + Task { + if case .success(let hasEntitlements) = await appDependencies.accountManager.hasEntitlement(forProductName: .networkProtection), hasEntitlements { + (window.rootViewController as? MainViewController)?.segueToVPN() + } else { + (window.rootViewController as? MainViewController)?.segueToPrivacyPro() + } + } + } + + func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem, appIsLaunching: Bool = false) { + Logger.general.debug("Handling shortcut item: \(shortcutItem.type)") + let autoClear = appDependencies.autoClear + Task { @MainActor in + + // This if/else could potentially be removed by ensuring previous autoClear calls (triggered during both Launch and Active states) are completed before proceeding. To be looked at in next milestones + if appIsLaunching { + await autoClear.clearDataIfEnabled() + } else { + await autoClear.clearDataIfEnabledAndTimeExpired(applicationState: .active) + } + + if shortcutItem.type == AppDelegate.ShortcutKey.clipboard, let query = UIPasteboard.general.string { + mainViewController.clearNavigationStack() + mainViewController.loadQueryInNewTab(query) + return + } + + if shortcutItem.type == AppDelegate.ShortcutKey.passwords { + mainViewController.clearNavigationStack() + // Give the `clearNavigationStack` call time to complete. + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) { [application] in + (application.window?.rootViewController as? MainViewController)?.launchAutofillLogins(openSearch: true, source: .appIconShortcut) + } + Pixel.fire(pixel: .autofillLoginsLaunchAppShortcut) + return + } + + if shortcutItem.type == AppDelegate.ShortcutKey.openVPNSettings { + presentNetworkProtectionStatusSettingsModal() + } + + } + } + +} + +extension Active { + + struct StateContext { + + let application: UIApplication + let appDependencies: AppDependencies + + } + + func makeStateContext() -> StateContext { + .init( + application: application, + appDependencies: appDependencies + ) } } diff --git a/DuckDuckGo/AppLifecycle/AppStates/Background.swift b/DuckDuckGo/AppLifecycle/AppStates/Background.swift index 71b0ab4c1a..f408b0919d 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Background.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/Background.swift @@ -17,16 +17,118 @@ // limitations under the License. // +import Foundation +import Combine +import DDGSync import UIKit +import Core struct Background: AppState { - init(application: UIApplication) { + private let lastBackgroundDate: Date = Date() + private let application: UIApplication + private var appDependencies: AppDependencies + var urlToOpen: URL? + var shortcutItemToHandle: UIApplicationShortcutItem? + + init(stateContext: Inactive.StateContext) { + application = stateContext.application + appDependencies = stateContext.appDependencies + urlToOpen = stateContext.urlToOpen + + run() + } + + init(stateContext: Launched.StateContext) { + application = stateContext.application + appDependencies = stateContext.appDependencies + urlToOpen = stateContext.urlToOpen + + run() + } + + mutating func run() { + let autoClear = appDependencies.autoClear + let privacyStore = appDependencies.privacyStore + let privacyProDataReporter = appDependencies.privacyProDataReporter + let voiceSearchHelper = appDependencies.voiceSearchHelper + let appSettings = appDependencies.appSettings + let autofillLoginSession = appDependencies.autofillLoginSession + let syncService = appDependencies.syncService + let syncDataProviders = appDependencies.syncDataProviders + let uiService = appDependencies.uiService + + if autoClear.isClearingEnabled || privacyStore.authenticationEnabled { + uiService.displayBlankSnapshotWindow(voiceSearchHelper: voiceSearchHelper, + addressBarPosition: appSettings.currentAddressBarPosition) + } + autoClear.startClearingTimer() + autofillLoginSession.endSession() + + suspendSync(syncService: syncService) + syncDataProviders.bookmarksAdapter.cancelFaviconsFetching(application) + privacyProDataReporter.saveApplicationLastSessionEnded() + + resetAppStartTime() + + // Kill switch for the new app delegate: + // If the .forceOldAppDelegate flag is set in the config, we mark a file as present. + // This switches the app to the old mode and silently crashes it in the background. + // When reopened, the app will reliably run the old flow. + if ContentBlocking.shared.privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .forceOldAppDelegate) { + (UIApplication.shared.delegate as? AppDelegate)?.forceOldAppDelegate() + fatalError("crash to ensure the app restarts using the old app delegate next time") + } + } + + private mutating func suspendSync(syncService: DDGSync) { + if syncService.isSyncInProgress { + Logger.sync.debug("Sync is in progress. Starting background task to allow it to gracefully complete.") + + var taskID: UIBackgroundTaskIdentifier! + taskID = UIApplication.shared.beginBackgroundTask(withName: "Cancelled Sync Completion Task") { + Logger.sync.debug("Forcing background task completion") + UIApplication.shared.endBackgroundTask(taskID) + } + appDependencies.syncDidFinishCancellable?.cancel() + appDependencies.syncDidFinishCancellable = syncService.isSyncInProgressPublisher.filter { !$0 } + .prefix(1) + .receive(on: DispatchQueue.main) + .sink { _ in + Logger.sync.debug("Ending background task") + UIApplication.shared.endBackgroundTask(taskID) + } + } + + syncService.scheduler.cancelSyncAndSuspendSyncQueue() + } + + private func resetAppStartTime() { + appDependencies.mainViewController.appDidFinishLaunchingStartTime = nil } } -struct DoubleBackground: AppState { - +extension Background { + + struct StateContext { + + let application: UIApplication + let lastBackgroundDate: Date + let urlToOpen: URL? + let shortcutItemToHandle: UIApplicationShortcutItem? + + let appDependencies: AppDependencies + + } + + func makeStateContext() -> StateContext { + .init(application: application, + lastBackgroundDate: lastBackgroundDate, + urlToOpen: urlToOpen, + shortcutItemToHandle: shortcutItemToHandle, + appDependencies: appDependencies) + } + } diff --git a/DuckDuckGo/AppLifecycle/AppStates/Inactive.swift b/DuckDuckGo/AppLifecycle/AppStates/Inactive.swift index 888ef34e09..10837bd2c8 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Inactive.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/Inactive.swift @@ -21,8 +21,41 @@ import UIKit struct Inactive: AppState { - init(application: UIApplication) { + private let application: UIApplication + private let appDependencies: AppDependencies + var urlToOpen: URL? + + init(stateContext: Active.StateContext) { + application = stateContext.application + appDependencies = stateContext.appDependencies + + let vpnFeatureVisibility = appDependencies.vpnFeatureVisibility + let accountManager = appDependencies.accountManager + let vpnWorkaround = appDependencies.vpnWorkaround + Task { @MainActor [application] in + await application.refreshVPNShortcuts(vpnFeatureVisibility: vpnFeatureVisibility, + accountManager: accountManager) + await vpnWorkaround.removeRedditSessionWorkaround() + } + } + +} + +extension Inactive { + + struct StateContext { + + let application: UIApplication + let urlToOpen: URL? + let appDependencies: AppDependencies + + } + + func makeStateContext() -> StateContext { + .init(application: application, + urlToOpen: urlToOpen, + appDependencies: appDependencies) } } diff --git a/DuckDuckGo/AppLifecycle/AppStates/Init.swift b/DuckDuckGo/AppLifecycle/AppStates/Init.swift index d68d714ea5..5054d8f27e 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Init.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/Init.swift @@ -17,6 +17,38 @@ // limitations under the License. // +import Core +import Crashes +import UIKit + +@MainActor struct Init: AppState { + @UserDefaultsWrapper(key: .didCrashDuringCrashHandlersSetUp, defaultValue: false) + var didCrashDuringCrashHandlersSetUp: Bool + + init() { + if !didCrashDuringCrashHandlersSetUp { + didCrashDuringCrashHandlersSetUp = true + CrashLogMessageExtractor.setUp(swapCxaThrow: false) + didCrashDuringCrashHandlersSetUp = false + } + } + +} + +extension Init { + + struct StateContext { + + let application: UIApplication + let didCrashDuringCrashHandlersSetUp: Bool + + } + + func makeStateContext(application: UIApplication) -> StateContext { + .init(application: application, + didCrashDuringCrashHandlersSetUp: didCrashDuringCrashHandlersSetUp) + } + } diff --git a/DuckDuckGo/AppLifecycle/AppStates/Launched.swift b/DuckDuckGo/AppLifecycle/AppStates/Launched.swift index 674bcf0b91..5bcbe14a26 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Launched.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/Launched.swift @@ -17,12 +17,635 @@ // limitations under the License. // +import Foundation +import Core +import Networking +import Configuration +import Crashes import UIKit +import Persistence +import BrowserServicesKit +import WidgetKit +import DDGSync +import RemoteMessaging +import Subscription +import WebKit +import Common +import Combine +import PixelKit +import PixelExperimentKit +@MainActor struct Launched: AppState { - init(application: UIApplication, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { + @UserDefaultsWrapper(key: .didCrashDuringCrashHandlersSetUp, defaultValue: false) + private var didCrashDuringCrashHandlersSetUp: Bool + private let crashCollection = CrashCollection(platform: .iOS) + private let bookmarksDatabase = BookmarksDatabase.make() + private let marketplaceAdPostbackManager = MarketplaceAdPostbackManager() + private let accountManager = AppDependencyProvider.shared.accountManager + private let tunnelController = AppDependencyProvider.shared.networkProtectionTunnelController + private let vpnFeatureVisibility = AppDependencyProvider.shared.vpnFeatureVisibility + private let appSettings = AppDependencyProvider.shared.appSettings + private let privacyStore = PrivacyUserDefaults() + private let voiceSearchHelper = VoiceSearchHelper() + private let autofillLoginSession = AppDependencyProvider.shared.autofillLoginSession + private let onboardingPixelReporter = OnboardingPixelReporter() + private let widgetRefreshModel = NetworkProtectionWidgetRefreshModel() + private let tipKitAppEventsHandler = TipKitAppEventHandler() + private let fireproofing = UserDefaultsFireproofing.xshared + + private let vpnWorkaround: VPNRedditSessionWorkaround + private let privacyProDataReporter: PrivacyProDataReporting + private let isTesting = ProcessInfo().arguments.contains("testing") + private let didFinishLaunchingStartTime = CFAbsoluteTimeGetCurrent() + private let crashReportUploaderOnboarding: CrashCollectionOnboarding + + // These should ideally be let properties instead of force-unwrapped. However, due to various initialization paths, such as database completion blocks, setting them up in advance is currently not feasible. Refactoring will be done once this code is streamlined. + private let uiService: UIService + private let unService: UNService + private let syncDataProviders: SyncDataProviders + private let syncService: DDGSync + private let isSyncInProgressCancellable: AnyCancellable + private let remoteMessagingClient: RemoteMessagingClient + private let subscriptionCookieManager: SubscriptionCookieManaging + private let autofillPixelReporter: AutofillPixelReporter + private let window: UIWindow + + private var mainViewController: MainViewController? + private var autoClear: AutoClear? + + var urlToOpen: URL? + var shortcutItemToHandle: UIApplicationShortcutItem? + + private let application: UIApplication + + // swiftlint:disable:next cyclomatic_complexity + init(stateContext: Init.StateContext) { + + @UserDefaultsWrapper(key: .privacyConfigCustomURL, defaultValue: nil) + var privacyConfigCustomURL: String? + + application = stateContext.application + privacyProDataReporter = PrivacyProDataReporter(fireproofing: fireproofing) + vpnWorkaround = VPNRedditSessionWorkaround(accountManager: accountManager, tunnelController: tunnelController) + crashReportUploaderOnboarding = CrashCollectionOnboarding(appSettings: AppDependencyProvider.shared.appSettings) + + defer { + let launchTime = CFAbsoluteTimeGetCurrent() - didFinishLaunchingStartTime + Pixel.fire(pixel: .appDidFinishLaunchingTime(time: Pixel.Event.BucketAggregation(number: launchTime)), + withAdditionalParameters: [PixelParameters.time: String(launchTime)]) + } + +#if targetEnvironment(simulator) + if ProcessInfo.processInfo.environment["UITESTING"] == "true" { + // Disable hardware keyboards. + let setHardwareLayout = NSSelectorFromString("setHardwareLayout:") + UITextInputMode.activeInputModes + // Filter `UIKeyboardInputMode`s. + .filter({ $0.responds(to: setHardwareLayout) }) + .forEach { $0.perform(setHardwareLayout, with: nil) } + } +#endif + +#if DEBUG + Pixel.isDryRun = true +#else + Pixel.isDryRun = false +#endif + + ContentBlocking.shared.onCriticalError = { [application] in + Task { @MainActor [application] in + let alertController = CriticalAlerts.makePreemptiveCrashAlert() + application.window?.rootViewController?.present(alertController, animated: true, completion: nil) + } + } + // Explicitly prepare ContentBlockingUpdating instance before Tabs are created + _ = ContentBlockingUpdating.shared + + // Can be removed after a couple of versions + cleanUpMacPromoExperiment2() + cleanUpIncrementalRolloutPixelTest() + + func cleanUpMacPromoExperiment2() { + UserDefaults.standard.removeObject(forKey: "com.duckduckgo.ios.macPromoMay23.exp2.cohort") + } + + func cleanUpIncrementalRolloutPixelTest() { + UserDefaults.standard.removeObject(forKey: "network-protection.incremental-feature-flag-test.has-sent-pixel") + } + + APIRequest.Headers.setUserAgent(DefaultUserAgentManager.duckDuckGoUserAgent) + + if isDebugBuild, let privacyConfigCustomURL, let url = URL(string: privacyConfigCustomURL) { + Configuration.setURLProvider(CustomConfigurationURLProvider(customPrivacyConfigurationURL: url)) + } else { + Configuration.setURLProvider(AppConfigurationURLProvider()) + } + + crashCollection.startAttachingCrashLogMessages { [application, crashReportUploaderOnboarding] pixelParameters, payloads, sendReport in + pixelParameters.forEach { params in + Pixel.fire(pixel: .dbCrashDetected, withAdditionalParameters: params, includedParameters: []) + + // Each crash comes with an `appVersion` parameter representing the version that the crash occurred on. + // This is to disambiguate the situation where a crash occurs, but isn't sent until the next update. + // If for some reason the parameter can't be found, fall back to the current version. + if let crashAppVersion = params[PixelParameters.appVersion] { + let dailyParameters = [PixelParameters.appVersion: crashAppVersion] + DailyPixel.fireDaily(.dbCrashDetectedDaily, withAdditionalParameters: dailyParameters) + } else { + DailyPixel.fireDaily(.dbCrashDetectedDaily) + } + } + + // Async dispatch because rootViewController may otherwise be nil here + DispatchQueue.main.async { + guard let viewController = application.window?.rootViewController else { return } + crashReportUploaderOnboarding.presentOnboardingIfNeeded(for: payloads, from: viewController, sendReport: sendReport) + } + } + + clearTmp() + + func clearTmp() { + let tmp = FileManager.default.temporaryDirectory + do { + try FileManager.default.removeItem(at: tmp) + } catch { + Logger.general.error("Failed to delete tmp dir") + } + } + + _ = DefaultUserAgentManager.shared + removeEmailWaitlistState() + + func removeEmailWaitlistState() { + EmailWaitlist.removeEmailState() + + let autofillStorage = EmailKeychainManager() + try? autofillStorage.deleteWaitlistState() + + // Remove the authentication state if this is a fresh install. + if !Database.shared.isDatabaseFileInitialized { + try? autofillStorage.deleteAuthenticationState() + } + } + + var shouldPresentInsufficientDiskSpaceAlertAndCrash = false + Database.shared.loadStore { context, error in + guard let context = context else { + + let parameters = [PixelParameters.applicationState: "\(stateContext.application.applicationState.rawValue)", + PixelParameters.dataAvailability: "\(stateContext.application.isProtectedDataAvailable)"] + + switch error { + case .none: + fatalError("Could not create database stack: Unknown Error") + case .some(CoreDataDatabase.Error.containerLocationCouldNotBePrepared(let underlyingError)): + Pixel.fire(pixel: .dbContainerInitializationError, + error: underlyingError, + withAdditionalParameters: parameters) + Thread.sleep(forTimeInterval: 1) + fatalError("Could not create database stack: \(underlyingError.localizedDescription)") + case .some(let error): + Pixel.fire(pixel: .dbInitializationError, + error: error, + withAdditionalParameters: parameters) + if error.isDiskFull { + shouldPresentInsufficientDiskSpaceAlertAndCrash = true + return + } else { + Thread.sleep(forTimeInterval: 1) + fatalError("Could not create database stack: \(error.localizedDescription)") + } + } + } + DatabaseMigration.migrate(to: context) + } + + switch BookmarksDatabaseSetup().loadStoreAndMigrate(bookmarksDatabase: bookmarksDatabase) { + case .success: + break + case .failure(let error): + Pixel.fire(pixel: .bookmarksCouldNotLoadDatabase, + error: error) + if error.isDiskFull { + shouldPresentInsufficientDiskSpaceAlertAndCrash = true + } else { + Thread.sleep(forTimeInterval: 1) + fatalError("Could not create database stack: \(error.localizedDescription)") + } + } + + WidgetCenter.shared.reloadAllTimelines() + + Favicons.shared.migrateFavicons(to: Favicons.Constants.maxFaviconSize) { + WidgetCenter.shared.reloadAllTimelines() + } + + PrivacyFeatures.httpsUpgrade.loadDataAsync() + + let variantManager = DefaultVariantManager() + let daxDialogs = DaxDialogs.shared + + // assign it here, because "did become active" is already too late and "viewWillAppear" + // has already been called on the HomeViewController so won't show the home row CTA + cleanUpATBAndAssignVariant(variantManager: variantManager, + daxDialogs: daxDialogs, + marketplaceAdPostbackManager: marketplaceAdPostbackManager) + + func cleanUpATBAndAssignVariant(variantManager: VariantManager, + daxDialogs: DaxDialogs, + marketplaceAdPostbackManager: MarketplaceAdPostbackManager) { + let historyMessageManager = HistoryMessageManager() + + AtbAndVariantCleanup.cleanup() + variantManager.assignVariantIfNeeded { _ in + let launchOptionsHandler = LaunchOptionsHandler() + + // MARK: perform first time launch logic here + // If it's running UI Tests check if the onboarding should be in a completed state. + if launchOptionsHandler.isUITesting && launchOptionsHandler.isOnboardingCompleted { + daxDialogs.dismiss() + } else { + daxDialogs.primeForUse() + } + + // New users don't see the message + historyMessageManager.dismiss() + + // Setup storage for marketplace postback + marketplaceAdPostbackManager.updateReturningUserValue() + } + } + + // MARK: Sync initialisation +#if DEBUG + let defaultEnvironment = ServerEnvironment.development +#else + let defaultEnvironment = ServerEnvironment.production +#endif + + let environment = ServerEnvironment( + UserDefaultsWrapper( + key: .syncEnvironment, + defaultValue: defaultEnvironment.description + ).wrappedValue + ) ?? defaultEnvironment + + var dryRun = false +#if DEBUG + dryRun = true +#endif + let isPhone = UIDevice.current.userInterfaceIdiom == .phone + let source = isPhone ? PixelKit.Source.iOS : PixelKit.Source.iPadOS + PixelKit.setUp(dryRun: dryRun, + appVersion: AppVersion.shared.versionNumber, + source: source.rawValue, + defaultHeaders: [:], + defaults: UserDefaults(suiteName: "\(Global.groupIdPrefix).app-configuration") ?? UserDefaults()) { (pixelName: String, headers: [String: String], parameters: [String: String], _, _, onComplete: @escaping PixelKit.CompletionBlock) in + + let url = URL.pixelUrl(forPixelNamed: pixelName) + let apiHeaders = APIRequestV2.HeadersV2(additionalHeaders: headers) + let request = APIRequestV2(url: url, method: .get, queryItems: parameters, headers: apiHeaders) + Task { + do { + _ = try await DefaultAPIService().fetch(request: request) + onComplete(true, nil) + } catch { + onComplete(false, error) + } + } + } + PixelKit.configureExperimentKit(featureFlagger: AppDependencyProvider.shared.featureFlagger, + eventTracker: ExperimentEventTracker(store: UserDefaults(suiteName: "\(Global.groupIdPrefix).app-configuration") ?? UserDefaults())) + + let syncErrorHandler = SyncErrorHandler() + + syncDataProviders = SyncDataProviders( + bookmarksDatabase: bookmarksDatabase, + secureVaultErrorReporter: SecureVaultReporter(), + settingHandlers: [FavoritesDisplayModeSyncHandler()], + favoritesDisplayModeStorage: FavoritesDisplayModeStorage(), + syncErrorHandler: syncErrorHandler, + faviconStoring: Favicons.shared, + tld: AppDependencyProvider.shared.storageCache.tld + ) + + syncService = DDGSync( + dataProvidersSource: syncDataProviders, + errorEvents: SyncErrorHandler(), + privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + environment: environment + ) + syncService.initializeIfNeeded() + isSyncInProgressCancellable = syncService.isSyncInProgressPublisher + .filter { $0 } + .sink { [weak syncService] _ in + DailyPixel.fire(pixel: .syncDaily, includedParameters: [.appVersion]) + syncService?.syncDailyStats.sendStatsIfNeeded(handler: { params in + Pixel.fire(pixel: .syncSuccessRateDaily, + withAdditionalParameters: params, + includedParameters: [.appVersion]) + }) + } + + remoteMessagingClient = RemoteMessagingClient( + bookmarksDatabase: bookmarksDatabase, + appSettings: AppDependencyProvider.shared.appSettings, + internalUserDecider: AppDependencyProvider.shared.internalUserDecider, + configurationStore: AppDependencyProvider.shared.configurationStore, + database: Database.shared, + errorEvents: RemoteMessagingStoreErrorHandling(), + remoteMessagingAvailabilityProvider: PrivacyConfigurationRemoteMessagingAvailabilityProvider( + privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager + ), + duckPlayerStorage: DefaultDuckPlayerStorage() + ) + remoteMessagingClient.registerBackgroundRefreshTaskHandler() + + let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability( + privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + purchasePlatform: .appStore) + + subscriptionCookieManager = Self.makeSubscriptionCookieManager(application: application) + + let homePageConfiguration = HomePageConfiguration(variantManager: AppDependencyProvider.shared.variantManager, + remoteMessagingClient: remoteMessagingClient, + privacyProDataReporter: privacyProDataReporter) + + + let previewsSource = TabPreviewsSource() + let historyManager = Self.makeHistoryManager() + let tabsModel = Self.prepareTabsModel(previewsSource: previewsSource) + + privacyProDataReporter.injectTabsModel(tabsModel) + + if shouldPresentInsufficientDiskSpaceAlertAndCrash { + window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = BlankSnapshotViewController(addressBarPosition: appSettings.currentAddressBarPosition, + voiceSearchHelper: voiceSearchHelper) + window.makeKeyAndVisible() + application.setWindow(window) + + let alertController = CriticalAlerts.makeInsufficientDiskSpaceAlert() + window.rootViewController?.present(alertController, animated: true, completion: nil) + } else { + let daxDialogsFactory = ExperimentContextualDaxDialogsFactory(contextualOnboardingLogic: daxDialogs, contextualOnboardingPixelReporter: onboardingPixelReporter) + let contextualOnboardingPresenter = ContextualOnboardingPresenter(variantManager: variantManager, daxDialogsFactory: daxDialogsFactory) + mainViewController = MainViewController(bookmarksDatabase: bookmarksDatabase, + bookmarksDatabaseCleaner: syncDataProviders.bookmarksAdapter.databaseCleaner, + historyManager: historyManager, + homePageConfiguration: homePageConfiguration, + syncService: syncService, + syncDataProviders: syncDataProviders, + appSettings: AppDependencyProvider.shared.appSettings, + previewsSource: previewsSource, + tabsModel: tabsModel, + syncPausedStateManager: syncErrorHandler, + privacyProDataReporter: privacyProDataReporter, + variantManager: variantManager, + contextualOnboardingPresenter: contextualOnboardingPresenter, + contextualOnboardingLogic: daxDialogs, + contextualOnboardingPixelReporter: onboardingPixelReporter, + subscriptionFeatureAvailability: subscriptionFeatureAvailability, + voiceSearchHelper: voiceSearchHelper, + featureFlagger: AppDependencyProvider.shared.featureFlagger, + fireproofing: fireproofing, + subscriptionCookieManager: subscriptionCookieManager, + textZoomCoordinator: Self.makeTextZoomCoordinator(), + websiteDataManager: Self.makeWebsiteDataManager(fireproofing: fireproofing), + appDidFinishLaunchingStartTime: didFinishLaunchingStartTime) + + mainViewController!.loadViewIfNeeded() + syncErrorHandler.alertPresenter = mainViewController + + window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = mainViewController + window.makeKeyAndVisible() + application.setWindow(window) + + let autoClear = AutoClear(worker: mainViewController!) + self.autoClear = autoClear + let applicationState = application.applicationState + let vpnWorkaround = vpnWorkaround + Task { + await autoClear.clearDataIfEnabled(applicationState: .init(with: applicationState)) + await vpnWorkaround.installRedditSessionWorkaround() + } + } + unService = UNService(window: window, accountManager: accountManager) + uiService = UIService(window: window) + + voiceSearchHelper.migrateSettingsFlagIfNecessary() + + // Task handler registration needs to happen before the end of `didFinishLaunching`, otherwise submitting a task can throw an exception. + // Having both in `didBecomeActive` can sometimes cause the exception when running on a physical device, so registration happens here. + AppConfigurationFetch.registerBackgroundRefreshTaskHandler() + + UNUserNotificationCenter.current().delegate = unService + + window.windowScene?.screenshotService?.delegate = uiService + ThemeManager.shared.updateUserInterfaceStyle(window: window) + + // Temporary logic for rollout of Autofill as on by default for new installs only + if AppDependencyProvider.shared.appSettings.autofillIsNewInstallForOnByDefault == nil { + AppDependencyProvider.shared.appSettings.setAutofillIsNewInstallForOnByDefault() + } + + NewTabPageIntroMessageSetup().perform() + + widgetRefreshModel.beginObservingVPNStatus() + + AppDependencyProvider.shared.subscriptionManager.loadInitialData() + + let autofillUsageMonitor = AutofillUsageMonitor() + autofillPixelReporter = AutofillPixelReporter( + userDefaults: .standard, + autofillEnabled: AppDependencyProvider.shared.appSettings.autofillCredentialsEnabled, + eventMapping: EventMapping {event, _, params, _ in + switch event { + case .autofillActiveUser: + Pixel.fire(pixel: .autofillActiveUser) + case .autofillEnabledUser: + Pixel.fire(pixel: .autofillEnabledUser) + case .autofillOnboardedUser: + Pixel.fire(pixel: .autofillOnboardedUser) + case .autofillToggledOn: + Pixel.fire(pixel: .autofillToggledOn, withAdditionalParameters: params ?? [:]) + if let autofillExtensionToggled = autofillUsageMonitor.autofillExtensionEnabled { + Pixel.fire(pixel: autofillExtensionToggled ? .autofillExtensionToggledOn : .autofillExtensionToggledOff, + withAdditionalParameters: params ?? [:]) + } + case .autofillToggledOff: + Pixel.fire(pixel: .autofillToggledOff, withAdditionalParameters: params ?? [:]) + if let autofillExtensionToggled = autofillUsageMonitor.autofillExtensionEnabled { + Pixel.fire(pixel: autofillExtensionToggled ? .autofillExtensionToggledOn : .autofillExtensionToggledOff, + withAdditionalParameters: params ?? [:]) + } + case .autofillLoginsStacked: + Pixel.fire(pixel: .autofillLoginsStacked, withAdditionalParameters: params ?? [:]) + default: + break + } + }, + installDate: StatisticsUserDefaults().installDate ?? Date()) + + _ = NotificationCenter.default.addObserver(forName: AppUserDefaults.Notifications.autofillEnabledChange, + object: nil, + queue: nil) { [autofillPixelReporter] _ in + autofillPixelReporter.updateAutofillEnabledStatus(AppDependencyProvider.shared.appSettings.autofillCredentialsEnabled) + } + + if stateContext.didCrashDuringCrashHandlersSetUp { + Pixel.fire(pixel: .crashOnCrashHandlersSetUp) + didCrashDuringCrashHandlersSetUp = false + } + + tipKitAppEventsHandler.appDidFinishLaunching() + } + + private var appDependencies: AppDependencies { + AppDependencies( + accountManager: accountManager, + vpnWorkaround: vpnWorkaround, + vpnFeatureVisibility: vpnFeatureVisibility, + appSettings: appSettings, + privacyStore: privacyStore, + uiService: uiService, + mainViewController: mainViewController!, + voiceSearchHelper: voiceSearchHelper, + autoClear: autoClear!, + autofillLoginSession: autofillLoginSession, + marketplaceAdPostbackManager: marketplaceAdPostbackManager, + syncService: syncService, + syncDataProviders: syncDataProviders, + isSyncInProgressCancellable: isSyncInProgressCancellable, + privacyProDataReporter: privacyProDataReporter, + remoteMessagingClient: remoteMessagingClient, + subscriptionService: SubscriptionService(subscriptionCookieManager: subscriptionCookieManager, + privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager), + onboardingPixelReporter: onboardingPixelReporter, + widgetRefreshModel: widgetRefreshModel, + autofillPixelReporter: autofillPixelReporter, + crashReportUploaderOnboarding: crashReportUploaderOnboarding + ) + } + + private static func makeSubscriptionCookieManager(application: UIApplication) -> SubscriptionCookieManaging { + let subscriptionCookieManager = SubscriptionCookieManager(subscriptionManager: AppDependencyProvider.shared.subscriptionManager, + currentCookieStore: { + guard let mainViewController = application.window?.rootViewController as? MainViewController, + mainViewController.tabManager.model.hasActiveTabs else { + // We shouldn't interact with WebKit's cookie store unless we have a WebView, + // eventually the subscription cookie will be refreshed on opening the first tab + return nil + } + return WKHTTPCookieStoreWrapper(store: WKWebsiteDataStore.current().httpCookieStore) + }, eventMapping: SubscriptionCookieManageEventPixelMapping()) + + let privacyConfigurationManager = ContentBlocking.shared.privacyConfigurationManager + + // Enable subscriptionCookieManager if feature flag is present + if privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) { + subscriptionCookieManager.enableSettingSubscriptionCookie() + } + + return subscriptionCookieManager + } + + private static func makeHistoryManager() -> HistoryManaging { + let provider = AppDependencyProvider.shared + switch HistoryManager.make(isAutocompleteEnabledByUser: provider.appSettings.autocomplete, + isRecentlyVisitedSitesEnabledByUser: provider.appSettings.recentlyVisitedSites, + privacyConfigManager: ContentBlocking.shared.privacyConfigurationManager, + tld: provider.storageCache.tld) { + + case .failure(let error): + Pixel.fire(pixel: .historyStoreLoadFailed, error: error) +// Commenting out as it didn't work anyway - the window was just always nil at this point +// if error.isDiskFull { +// self.presentInsufficientDiskSpaceAlert() +// } else { +// self.presentPreemptiveCrashAlert() +// } + return NullHistoryManager() + + case .success(let historyManager): + return historyManager + } + } + + private static func prepareTabsModel(previewsSource: TabPreviewsSource = TabPreviewsSource(), + appSettings: AppSettings = AppDependencyProvider.shared.appSettings, + isDesktop: Bool = UIDevice.current.userInterfaceIdiom == .pad) -> TabsModel { + let isPadDevice = UIDevice.current.userInterfaceIdiom == .pad + let tabsModel: TabsModel + if AutoClearSettingsModel(settings: appSettings) != nil { + tabsModel = TabsModel(desktop: isPadDevice) + tabsModel.save() + previewsSource.removeAllPreviews() + } else { + if let storedModel = TabsModel.get() { + // Save new model in case of migration + storedModel.save() + tabsModel = storedModel + } else { + tabsModel = TabsModel(desktop: isPadDevice) + } + } + return tabsModel + } + + private static func makeTextZoomCoordinator() -> TextZoomCoordinator { + let provider = AppDependencyProvider.shared + let storage = TextZoomStorage() + + return TextZoomCoordinator(appSettings: provider.appSettings, + storage: storage, + featureFlagger: provider.featureFlagger) + } + + private static func makeWebsiteDataManager(fireproofing: Fireproofing, + dataStoreIDManager: DataStoreIDManaging = DataStoreIDManager.shared) -> WebsiteDataManaging { + return WebCacheManager(cookieStorage: MigratableCookieStorage(), + fireproofing: fireproofing, + dataStoreIDManager: dataStoreIDManager) + } + +} + +extension Launched { + + struct StateContext { + + let application: UIApplication + let didFinishLaunchingStartTime: CFAbsoluteTime + let urlToOpen: URL? + let shortcutItemToHandle: UIApplicationShortcutItem? + let appDependencies: AppDependencies + + } + + func makeStateContext() -> StateContext { + .init(application: application, + didFinishLaunchingStartTime: didFinishLaunchingStartTime, + urlToOpen: urlToOpen, + shortcutItemToHandle: shortcutItemToHandle, + appDependencies: appDependencies) + } + +} + +extension UIApplication { + + func setWindow(_ window: UIWindow?) { + (delegate as? AppDelegate)?.window = window + } + + var window: UIWindow? { + delegate?.window ?? nil } } diff --git a/DuckDuckGo/AppLifecycle/AppStates/Testing.swift b/DuckDuckGo/AppLifecycle/AppStates/Testing.swift new file mode 100644 index 0000000000..2363721731 --- /dev/null +++ b/DuckDuckGo/AppLifecycle/AppStates/Testing.swift @@ -0,0 +1,47 @@ +// +// Testing.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Core +import UIKit + +@MainActor +struct Testing: AppState { + + init(application: UIApplication) { + Pixel.isDryRun = true + _ = DefaultUserAgentManager.shared + Database.shared.loadStore { _, _ in } + _ = BookmarksDatabaseSetup().loadStoreAndMigrate(bookmarksDatabase: BookmarksDatabase.make()) + + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIStoryboard.init(name: "LaunchScreen", bundle: nil).instantiateInitialViewController() + + let blockingDelegate = BlockingNavigationDelegate() + let webView = blockingDelegate.prepareWebView() + window.rootViewController?.view.addSubview(webView) + window.rootViewController?.view.backgroundColor = .red + webView.frame = CGRect(x: 10, y: 10, width: 300, height: 300) + + application.setWindow(window) + + let request = URLRequest(url: URL(string: "about:blank")!) + webView.load(request) + } + +} diff --git a/DuckDuckGo/AppServices/SubscriptionService.swift b/DuckDuckGo/AppServices/SubscriptionService.swift new file mode 100644 index 0000000000..fb5c9680d8 --- /dev/null +++ b/DuckDuckGo/AppServices/SubscriptionService.swift @@ -0,0 +1,42 @@ +// +// SubscriptionService.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 Subscription +import Combine +import BrowserServicesKit + +final class SubscriptionService { + + let subscriptionCookieManager: SubscriptionCookieManaging + private var cancellables: Set = [] + + var onPrivacyConfigurationUpdate: (() -> Void)? + + init(subscriptionCookieManager: SubscriptionCookieManaging, + privacyConfigurationManager: PrivacyConfigurationManaging) { + self.subscriptionCookieManager = subscriptionCookieManager + privacyConfigurationManager.updatesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.onPrivacyConfigurationUpdate?() + } + .store(in: &cancellables) + } + +} diff --git a/DuckDuckGo/AppServices/UIService.swift b/DuckDuckGo/AppServices/UIService.swift new file mode 100644 index 0000000000..25fd00983e --- /dev/null +++ b/DuckDuckGo/AppServices/UIService.swift @@ -0,0 +1,119 @@ +// +// UIService.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +final class UIService: NSObject { + + var overlayWindow: UIWindow? + let window: UIWindow + + var showKeyboardIfSettingOn = true // temporary + + init(window: UIWindow) { + self.window = window + } + + func displayBlankSnapshotWindow(voiceSearchHelper: VoiceSearchHelper, + addressBarPosition: AddressBarPosition) { + guard overlayWindow == nil else { return } + + overlayWindow = UIWindow(frame: window.frame) + overlayWindow?.windowLevel = UIWindow.Level.alert + + // TODO: most likely we do not need voiceSearchHelper for BlankSnapshotVC + let overlay = BlankSnapshotViewController(addressBarPosition: addressBarPosition, voiceSearchHelper: voiceSearchHelper) + overlay.delegate = self + + overlayWindow?.rootViewController = overlay + overlayWindow?.makeKeyAndVisible() + window.isHidden = true + } + + func removeOverlay() { + if overlayWindow == nil { + tryToObtainOverlayWindow() + } + + if let overlay = overlayWindow { + overlay.isHidden = true + overlayWindow = nil + window.makeKeyAndVisible() + } + } + + func tryToObtainOverlayWindow() { + for window in UIApplication.shared.foregroundSceneWindows where window.rootViewController is BlankSnapshotViewController { + overlayWindow = window + return + } + } + + func displayAuthenticationWindow() { + guard overlayWindow == nil else { return } + overlayWindow = UIWindow(frame: window.frame) + overlayWindow?.windowLevel = UIWindow.Level.alert + overlayWindow?.rootViewController = AuthenticationViewController.loadFromStoryboard() + overlayWindow?.makeKeyAndVisible() + window.isHidden = true + } + +} + +extension UIService: BlankSnapshotViewRecoveringDelegate { + + func recoverFromPresenting(controller: BlankSnapshotViewController) { + if overlayWindow == nil { + tryToObtainOverlayWindow() + } + + overlayWindow?.isHidden = true + overlayWindow = nil + window.makeKeyAndVisible() + } + +} + +extension UIService: UIScreenshotServiceDelegate { + + func screenshotService(_ screenshotService: UIScreenshotService, + generatePDFRepresentationWithCompletion completionHandler: @escaping (Data?, Int, CGRect) -> Void) { + guard let mainViewController = window.rootViewController as? MainViewController, // todo, will it be needed? + let webView = mainViewController.currentTab?.webView else { + completionHandler(nil, 0, .zero) + return + } + + let zoomScale = webView.scrollView.zoomScale + + // The PDF's coordinate space has its origin at the bottom left, so the view's origin.y needs to be converted + let visibleBounds = CGRect( + x: webView.scrollView.contentOffset.x / zoomScale, + y: (webView.scrollView.contentSize.height - webView.scrollView.contentOffset.y - webView.bounds.height) / zoomScale, + width: webView.bounds.width / zoomScale, + height: webView.bounds.height / zoomScale + ) + + webView.createPDF { result in + let data = try? result.get() + completionHandler(data, 0, visibleBounds) + } + } + +} diff --git a/DuckDuckGo/AppServices/UNService.swift b/DuckDuckGo/AppServices/UNService.swift new file mode 100644 index 0000000000..f7874d03f7 --- /dev/null +++ b/DuckDuckGo/AppServices/UNService.swift @@ -0,0 +1,70 @@ +// +// UNService.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 NotificationCenter +import Core +import Subscription + +final class UNService: NSObject { + + let window: UIWindow + let accountManager: AccountManager + + init(window: UIWindow, + accountManager: AccountManager) { + self.window = window + self.accountManager = accountManager + } + +} + +extension UNService: UNUserNotificationCenterDelegate { + + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler(.banner) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + if response.actionIdentifier == UNNotificationDefaultActionIdentifier { + let identifier = response.notification.request.identifier + + if NetworkProtectionNotificationIdentifier(rawValue: identifier) != nil { + presentNetworkProtectionStatusSettingsModal() + } + } + + completionHandler() + } + + private func presentNetworkProtectionStatusSettingsModal() { + Task { @MainActor in + if case .success(let hasEntitlements) = await accountManager.hasEntitlement(forProductName: .networkProtection), hasEntitlements { + (window.rootViewController as? MainViewController)?.segueToVPN() + } else { + (window.rootViewController as? MainViewController)?.segueToPrivacyPro() + } + } + } + +} diff --git a/DuckDuckGo/AppSettings.swift b/DuckDuckGo/AppSettings.swift index f7242d9a88..106a92d9f9 100644 --- a/DuckDuckGo/AppSettings.swift +++ b/DuckDuckGo/AppSettings.swift @@ -84,6 +84,8 @@ protocol AppSettings: AnyObject, AppDebugSettings { var duckPlayerMode: DuckPlayerMode { get set } var duckPlayerAskModeOverlayHidden: Bool { get set } var duckPlayerOpenInNewTab: Bool { get set } + + var appBehavior: AppBehavior? { get set } } protocol AppDebugSettings { diff --git a/DuckDuckGo/AppShortcuts.swift b/DuckDuckGo/AppShortcuts.swift new file mode 100644 index 0000000000..aff44e84cf --- /dev/null +++ b/DuckDuckGo/AppShortcuts.swift @@ -0,0 +1,43 @@ +// +// AppShortcuts.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 Subscription +import UIKit + +extension UIApplication { + + func refreshVPNShortcuts(vpnFeatureVisibility: DefaultNetworkProtectionVisibility, accountManager: AccountManager) async { + guard vpnFeatureVisibility.shouldShowVPNShortcut(), + case .success(true) = await accountManager.hasEntitlement(forProductName: .networkProtection, + cachePolicy: .returnCacheDataDontLoad) + else { + shortcutItems = nil + return + } + + shortcutItems = [ + UIApplicationShortcutItem(type: AppDelegate.ShortcutKey.openVPNSettings, + localizedTitle: UserText.netPOpenVPNQuickAction, + localizedSubtitle: nil, + icon: UIApplicationShortcutIcon(templateImageName: "VPN-16"), + userInfo: nil) + ] + } + +} diff --git a/DuckDuckGo/AppUserDefaults.swift b/DuckDuckGo/AppUserDefaults.swift index 2c17e2ac1e..dafbd93848 100644 --- a/DuckDuckGo/AppUserDefaults.swift +++ b/DuckDuckGo/AppUserDefaults.swift @@ -80,6 +80,8 @@ public class AppUserDefaults: AppSettings { static let duckPlayerMode = "com.duckduckgo.ios.duckPlayerMode" static let duckPlayerAskModeOverlayHidden = "com.duckduckgo.ios.duckPlayerAskModeOverlayHidden" static let duckPlayerOpenInNewTab = "com.duckduckgo.ios.duckPlayerOpenInNewTab" + + static let appBehavior = "com.duckduckgo.ios.appBehavior" } private struct DebugKeys { @@ -146,7 +148,18 @@ public class AppUserDefaults: AppSettings { } } - + + var appBehavior: AppBehavior? { + get { + let value = userDefaults?.string(forKey: Keys.appBehavior) ?? "" + return AppBehavior(rawValue: value) + } + + set { + userDefaults?.setValue(newValue?.rawValue, forKey: Keys.appBehavior) + } + } + var autoClearAction: AutoClearSettingsModel.Action { get { diff --git a/DuckDuckGo/AutoClear.swift b/DuckDuckGo/AutoClear.swift index 2e1abbc02b..e2e7f93e20 100644 --- a/DuckDuckGo/AutoClear.swift +++ b/DuckDuckGo/AutoClear.swift @@ -34,7 +34,7 @@ protocol AutoClearWorker { class AutoClear { - private let worker: AutoClearWorker + private let worker: AutoClearWorker // shouldn't it be weak? private var timestamp: TimeInterval? private let appSettings: AppSettings diff --git a/DuckDuckGo/BlankSnapshotViewController.swift b/DuckDuckGo/BlankSnapshotViewController.swift index 0268aa3f2b..f765c8d02e 100644 --- a/DuckDuckGo/BlankSnapshotViewController.swift +++ b/DuckDuckGo/BlankSnapshotViewController.swift @@ -36,15 +36,15 @@ class BlankSnapshotViewController: UIViewController { let menuButton = MenuButton() var tabSwitcherButton: TabSwitcherButton! - let appSettings: AppSettings + let addressBarPosition: AddressBarPosition let voiceSearchHelper: VoiceSearchHelperProtocol var viewCoordinator: MainViewCoordinator! weak var delegate: BlankSnapshotViewRecoveringDelegate? - init(appSettings: AppSettings, voiceSearchHelper: VoiceSearchHelperProtocol) { - self.appSettings = appSettings + init(addressBarPosition: AddressBarPosition, voiceSearchHelper: VoiceSearchHelperProtocol) { + self.addressBarPosition = addressBarPosition self.voiceSearchHelper = voiceSearchHelper super.init(nibName: nil, bundle: nil) } @@ -59,7 +59,7 @@ class BlankSnapshotViewController: UIViewController { tabSwitcherButton = TabSwitcherButton() viewCoordinator = MainViewFactory.createViewHierarchy(view, voiceSearchHelper: voiceSearchHelper) - if appSettings.currentAddressBarPosition.isBottom { + if addressBarPosition.isBottom { viewCoordinator.moveAddressBarToPosition(.bottom) viewCoordinator.hideToolbarSeparator() } @@ -231,7 +231,7 @@ extension BlankSnapshotViewController { private func updateStatusBarBackgroundColor() { let theme = ThemeManager.shared.currentTheme - if appSettings.currentAddressBarPosition == .bottom { + if addressBarPosition == .bottom { viewCoordinator.statusBackground.backgroundColor = theme.backgroundColor } else { if AppWidthObserver.shared.isPad && traitCollection.horizontalSizeClass == .regular { diff --git a/DuckDuckGo/NewAppDelegate.swift b/DuckDuckGo/NewAppDelegate.swift new file mode 100644 index 0000000000..6d6adc6bc8 --- /dev/null +++ b/DuckDuckGo/NewAppDelegate.swift @@ -0,0 +1,66 @@ +// +// NewAppDelegate.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +final class NewAppDelegate: NSObject, UIApplicationDelegate, DDGApp { + + private let appStateMachine: AppStateMachine = AppStateMachine() + private let isTesting: Bool = ProcessInfo().arguments.contains("testing") + + var privacyProDataReporter: PrivacyProDataReporting? { + (appStateMachine.currentState as? Active)?.appDependencies.privacyProDataReporter // just for now, we have to get rid of this anti pattern + } + + func initialize() { } // init code will happen inside AppStateMachine/Init state .init() + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + appStateMachine.handle(.launching(application, isTesting: isTesting)) + return true + } + + func applicationDidBecomeActive(_ application: UIApplication) { + appStateMachine.handle(.activating) + } + + func applicationWillResignActive(_ application: UIApplication) { + appStateMachine.handle(.suspending) + } + + func applicationDidEnterBackground(_ application: UIApplication) { + appStateMachine.handle(.backgrounding) + } + + func application(_ application: UIApplication, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void) { + appStateMachine.handle(.handleShortcutItem(shortcutItem)) + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + appStateMachine.handle(.openURL(url)) + return true + } + + func refreshRemoteMessages() { + // part of debug menu, let's not support it in the first iteration + } + + +} diff --git a/DuckDuckGo/OldAppDelegate.swift b/DuckDuckGo/OldAppDelegate.swift new file mode 100644 index 0000000000..a48ae41636 --- /dev/null +++ b/DuckDuckGo/OldAppDelegate.swift @@ -0,0 +1,1263 @@ +// +// OldAppDelegate.swift +// DuckDuckGo +// +// Copyright © 2017 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import Combine +import Common +import Core +import UserNotifications +import Kingfisher +import WidgetKit +import BackgroundTasks +import BrowserServicesKit +import Bookmarks +import Persistence +import Crashes +import Configuration +import Networking +import DDGSync +import RemoteMessaging +import SyncDataProviders +import Subscription +import NetworkProtection +import PixelKit +import PixelExperimentKit +import WebKit +import os.log + +@MainActor +final class OldAppDelegate: NSObject, UIApplicationDelegate, DDGApp { + + private var testing = false + var appIsLaunching = false + var overlayWindow: UIWindow? + var window: UIWindow? { + get { + appDelegate?.window + } + set { + appDelegate?.window = newValue + } + } + + private lazy var privacyStore = PrivacyUserDefaults() + private var bookmarksDatabase: CoreDataDatabase = BookmarksDatabase.make() + + private let widgetRefreshModel = NetworkProtectionWidgetRefreshModel() + private let tunnelDefaults = UserDefaults.networkProtectionGroupDefaults + + @MainActor + private lazy var vpnWorkaround: VPNRedditSessionWorkaround = { + return VPNRedditSessionWorkaround( + accountManager: AppDependencyProvider.shared.accountManager, + tunnelController: AppDependencyProvider.shared.networkProtectionTunnelController + ) + }() + + private var autoClear: AutoClear? + private var showKeyboardIfSettingOn = true + private var lastBackgroundDate: Date? + + private(set) var homePageConfiguration: HomePageConfiguration! + + private(set) var remoteMessagingClient: RemoteMessagingClient! + + private(set) var syncService: DDGSync! + private(set) var syncDataProviders: SyncDataProviders! + private var syncDidFinishCancellable: AnyCancellable? + private var syncStateCancellable: AnyCancellable? + private var isSyncInProgressCancellable: AnyCancellable? + + private let crashCollection = CrashCollection(platform: .iOS) + private var crashReportUploaderOnboarding: CrashCollectionOnboarding? + + private var autofillPixelReporter: AutofillPixelReporter? + private var autofillUsageMonitor = AutofillUsageMonitor() + + private(set) var subscriptionFeatureAvailability: SubscriptionFeatureAvailability! + private var subscriptionCookieManager: SubscriptionCookieManaging! + private var subscriptionCookieManagerFeatureFlagCancellable: AnyCancellable? + var privacyProDataReporter: PrivacyProDataReporting? + + // MARK: - Feature specific app event handlers + + private let tipKitAppEventsHandler = TipKitAppEventHandler() + + // MARK: lifecycle + + @UserDefaultsWrapper(key: .privacyConfigCustomURL, defaultValue: nil) + private var privacyConfigCustomURL: String? + + var accountManager: AccountManager { + AppDependencyProvider.shared.accountManager + } + + @UserDefaultsWrapper(key: .didCrashDuringCrashHandlersSetUp, defaultValue: false) + private var didCrashDuringCrashHandlersSetUp: Bool + + private let launchOptionsHandler = LaunchOptionsHandler() + private let onboardingPixelReporter = OnboardingPixelReporter() + + private let voiceSearchHelper = VoiceSearchHelper() + + private let marketplaceAdPostbackManager = MarketplaceAdPostbackManager() + + private var didFinishLaunchingStartTime: CFAbsoluteTime? + + private weak var appDelegate: AppDelegate? + init(with appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } + + func initialize() { + if !didCrashDuringCrashHandlersSetUp { + didCrashDuringCrashHandlersSetUp = true + CrashLogMessageExtractor.setUp(swapCxaThrow: false) + didCrashDuringCrashHandlersSetUp = false + } + } + + // swiftlint:disable:next function_body_length + // swiftlint:disable:next cyclomatic_complexity + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + didFinishLaunchingStartTime = CFAbsoluteTimeGetCurrent() + defer { + if let didFinishLaunchingStartTime { + let launchTime = CFAbsoluteTimeGetCurrent() - didFinishLaunchingStartTime + Pixel.fire(pixel: .appDidFinishLaunchingTime(time: Pixel.Event.BucketAggregation(number: launchTime)), + withAdditionalParameters: [PixelParameters.time: String(launchTime)]) + } + } + + +#if targetEnvironment(simulator) + if ProcessInfo.processInfo.environment["UITESTING"] == "true" { + // Disable hardware keyboards. + let setHardwareLayout = NSSelectorFromString("setHardwareLayout:") + UITextInputMode.activeInputModes + // Filter `UIKeyboardInputMode`s. + .filter({ $0.responds(to: setHardwareLayout) }) + .forEach { $0.perform(setHardwareLayout, with: nil) } + } +#endif + +#if DEBUG + Pixel.isDryRun = true +#else + Pixel.isDryRun = false +#endif + + ContentBlocking.shared.onCriticalError = presentPreemptiveCrashAlert + // Explicitly prepare ContentBlockingUpdating instance before Tabs are created + _ = ContentBlockingUpdating.shared + + // Can be removed after a couple of versions + cleanUpMacPromoExperiment2() + cleanUpIncrementalRolloutPixelTest() + + APIRequest.Headers.setUserAgent(DefaultUserAgentManager.duckDuckGoUserAgent) + + if isDebugBuild, let privacyConfigCustomURL, let url = URL(string: privacyConfigCustomURL) { + Configuration.setURLProvider(CustomConfigurationURLProvider(customPrivacyConfigurationURL: url)) + } else { + Configuration.setURLProvider(AppConfigurationURLProvider()) + } + + crashCollection.startAttachingCrashLogMessages { pixelParameters, payloads, sendReport in + pixelParameters.forEach { params in + Pixel.fire(pixel: .dbCrashDetected, withAdditionalParameters: params, includedParameters: []) + + // Each crash comes with an `appVersion` parameter representing the version that the crash occurred on. + // This is to disambiguate the situation where a crash occurs, but isn't sent until the next update. + // If for some reason the parameter can't be found, fall back to the current version. + if let crashAppVersion = params[PixelParameters.appVersion] { + let dailyParameters = [PixelParameters.appVersion: crashAppVersion] + DailyPixel.fireDaily(.dbCrashDetectedDaily, withAdditionalParameters: dailyParameters) + } else { + DailyPixel.fireDaily(.dbCrashDetectedDaily) + } + } + + // Async dispatch because rootViewController may otherwise be nil here + DispatchQueue.main.async { + guard let viewController = self.window?.rootViewController else { return } + + let crashReportUploaderOnboarding = CrashCollectionOnboarding(appSettings: AppDependencyProvider.shared.appSettings) + crashReportUploaderOnboarding.presentOnboardingIfNeeded(for: payloads, from: viewController, sendReport: sendReport) + self.crashReportUploaderOnboarding = crashReportUploaderOnboarding + } + } + + clearTmp() + + _ = DefaultUserAgentManager.shared + testing = ProcessInfo().arguments.contains("testing") + if testing { + Pixel.isDryRun = true + _ = DefaultUserAgentManager.shared + Database.shared.loadStore { _, _ in } + _ = BookmarksDatabaseSetup().loadStoreAndMigrate(bookmarksDatabase: bookmarksDatabase) + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = UIStoryboard.init(name: "LaunchScreen", bundle: nil).instantiateInitialViewController() + + let blockingDelegate = BlockingNavigationDelegate() + let webView = blockingDelegate.prepareWebView() + window?.rootViewController?.view.addSubview(webView) + window?.rootViewController?.view.backgroundColor = .red + webView.frame = CGRect(x: 10, y: 10, width: 300, height: 300) + + let request = URLRequest(url: URL(string: "about:blank")!) + webView.load(request) + + return true + } + + removeEmailWaitlistState() + + var shouldPresentInsufficientDiskSpaceAlertAndCrash = false + Database.shared.loadStore { context, error in + guard let context = context else { + + let parameters = [PixelParameters.applicationState: "\(application.applicationState.rawValue)", + PixelParameters.dataAvailability: "\(application.isProtectedDataAvailable)"] + + switch error { + case .none: + fatalError("Could not create database stack: Unknown Error") + case .some(CoreDataDatabase.Error.containerLocationCouldNotBePrepared(let underlyingError)): + Pixel.fire(pixel: .dbContainerInitializationError, + error: underlyingError, + withAdditionalParameters: parameters) + Thread.sleep(forTimeInterval: 1) + fatalError("Could not create database stack: \(underlyingError.localizedDescription)") + case .some(let error): + Pixel.fire(pixel: .dbInitializationError, + error: error, + withAdditionalParameters: parameters) + if error.isDiskFull { + shouldPresentInsufficientDiskSpaceAlertAndCrash = true + return + } else { + Thread.sleep(forTimeInterval: 1) + fatalError("Could not create database stack: \(error.localizedDescription)") + } + } + } + DatabaseMigration.migrate(to: context) + } + + switch BookmarksDatabaseSetup().loadStoreAndMigrate(bookmarksDatabase: bookmarksDatabase) { + case .success: + break + case .failure(let error): + Pixel.fire(pixel: .bookmarksCouldNotLoadDatabase, + error: error) + if error.isDiskFull { + shouldPresentInsufficientDiskSpaceAlertAndCrash = true + } else { + Thread.sleep(forTimeInterval: 1) + fatalError("Could not create database stack: \(error.localizedDescription)") + } + } + + WidgetCenter.shared.reloadAllTimelines() + + Favicons.shared.migrateFavicons(to: Favicons.Constants.maxFaviconSize) { + WidgetCenter.shared.reloadAllTimelines() + } + + PrivacyFeatures.httpsUpgrade.loadDataAsync() + + let variantManager = DefaultVariantManager() + let daxDialogs = DaxDialogs.shared + + // assign it here, because "did become active" is already too late and "viewWillAppear" + // has already been called on the HomeViewController so won't show the home row CTA + cleanUpATBAndAssignVariant(variantManager: variantManager, daxDialogs: daxDialogs) + + // MARK: Sync initialisation +#if DEBUG + let defaultEnvironment = ServerEnvironment.development +#else + let defaultEnvironment = ServerEnvironment.production +#endif + + let environment = ServerEnvironment( + UserDefaultsWrapper( + key: .syncEnvironment, + defaultValue: defaultEnvironment.description + ).wrappedValue + ) ?? defaultEnvironment + + var dryRun = false +#if DEBUG + dryRun = true +#endif + let isPhone = UIDevice.current.userInterfaceIdiom == .phone + let source = isPhone ? PixelKit.Source.iOS : PixelKit.Source.iPadOS + PixelKit.setUp(dryRun: dryRun, + appVersion: AppVersion.shared.versionNumber, + source: source.rawValue, + defaultHeaders: [:], + defaults: UserDefaults(suiteName: "\(Global.groupIdPrefix).app-configuration") ?? UserDefaults()) { (pixelName: String, headers: [String: String], parameters: [String: String], _, _, onComplete: @escaping PixelKit.CompletionBlock) in + + let url = URL.pixelUrl(forPixelNamed: pixelName) + let apiHeaders = APIRequestV2.HeadersV2(additionalHeaders: headers) + let request = APIRequestV2(url: url, method: .get, queryItems: parameters, headers: apiHeaders) + Task { + do { + _ = try await DefaultAPIService().fetch(request: request) + onComplete(true, nil) + } catch { + onComplete(false, error) + } + } + } + PixelKit.configureExperimentKit(featureFlagger: AppDependencyProvider.shared.featureFlagger, + eventTracker: ExperimentEventTracker(store: UserDefaults(suiteName: "\(Global.groupIdPrefix).app-configuration") ?? UserDefaults())) + + let syncErrorHandler = SyncErrorHandler() + + syncDataProviders = SyncDataProviders( + bookmarksDatabase: bookmarksDatabase, + secureVaultErrorReporter: SecureVaultReporter(), + settingHandlers: [FavoritesDisplayModeSyncHandler()], + favoritesDisplayModeStorage: FavoritesDisplayModeStorage(), + syncErrorHandler: syncErrorHandler, + faviconStoring: Favicons.shared, + tld: AppDependencyProvider.shared.storageCache.tld + ) + + let syncService = DDGSync( + dataProvidersSource: syncDataProviders, + errorEvents: SyncErrorHandler(), + privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + environment: environment + ) + syncService.initializeIfNeeded() + self.syncService = syncService + + let fireproofing = UserDefaultsFireproofing.xshared + privacyProDataReporter = PrivacyProDataReporter(fireproofing: fireproofing) + + isSyncInProgressCancellable = syncService.isSyncInProgressPublisher + .filter { $0 } + .sink { [weak syncService] _ in + DailyPixel.fire(pixel: .syncDaily, includedParameters: [.appVersion]) + syncService?.syncDailyStats.sendStatsIfNeeded(handler: { params in + Pixel.fire(pixel: .syncSuccessRateDaily, + withAdditionalParameters: params, + includedParameters: [.appVersion]) + }) + } + + remoteMessagingClient = RemoteMessagingClient( + bookmarksDatabase: bookmarksDatabase, + appSettings: AppDependencyProvider.shared.appSettings, + internalUserDecider: AppDependencyProvider.shared.internalUserDecider, + configurationStore: AppDependencyProvider.shared.configurationStore, + database: Database.shared, + errorEvents: RemoteMessagingStoreErrorHandling(), + remoteMessagingAvailabilityProvider: PrivacyConfigurationRemoteMessagingAvailabilityProvider( + privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager + ), + duckPlayerStorage: DefaultDuckPlayerStorage() + ) + remoteMessagingClient.registerBackgroundRefreshTaskHandler() + + subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability( + privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + purchasePlatform: .appStore) + + subscriptionCookieManager = makeSubscriptionCookieManager() + + homePageConfiguration = HomePageConfiguration(variantManager: AppDependencyProvider.shared.variantManager, + remoteMessagingClient: remoteMessagingClient, + privacyProDataReporter: privacyProDataReporter!) + + let previewsSource = TabPreviewsSource() + let historyManager = makeHistoryManager() + let tabsModel = prepareTabsModel(previewsSource: previewsSource) + + privacyProDataReporter?.injectTabsModel(tabsModel) + + if shouldPresentInsufficientDiskSpaceAlertAndCrash { + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = BlankSnapshotViewController(addressBarPosition: AppDependencyProvider.shared.appSettings.currentAddressBarPosition, + voiceSearchHelper: voiceSearchHelper) + window?.makeKeyAndVisible() + + presentInsufficientDiskSpaceAlert() + } else { + let daxDialogsFactory = ExperimentContextualDaxDialogsFactory(contextualOnboardingLogic: daxDialogs, contextualOnboardingPixelReporter: onboardingPixelReporter) + let contextualOnboardingPresenter = ContextualOnboardingPresenter(variantManager: variantManager, daxDialogsFactory: daxDialogsFactory) + let main = MainViewController(bookmarksDatabase: bookmarksDatabase, + bookmarksDatabaseCleaner: syncDataProviders.bookmarksAdapter.databaseCleaner, + historyManager: historyManager, + homePageConfiguration: homePageConfiguration, + syncService: syncService, + syncDataProviders: syncDataProviders, + appSettings: AppDependencyProvider.shared.appSettings, + previewsSource: previewsSource, + tabsModel: tabsModel, + syncPausedStateManager: syncErrorHandler, + privacyProDataReporter: privacyProDataReporter!, + variantManager: variantManager, + contextualOnboardingPresenter: contextualOnboardingPresenter, + contextualOnboardingLogic: daxDialogs, + contextualOnboardingPixelReporter: onboardingPixelReporter, + subscriptionFeatureAvailability: subscriptionFeatureAvailability, + voiceSearchHelper: voiceSearchHelper, + featureFlagger: AppDependencyProvider.shared.featureFlagger, + fireproofing: fireproofing, + subscriptionCookieManager: subscriptionCookieManager, + textZoomCoordinator: makeTextZoomCoordinator(), + websiteDataManager: makeWebsiteDataManager(fireproofing: fireproofing), + appDidFinishLaunchingStartTime: didFinishLaunchingStartTime) + + main.loadViewIfNeeded() + syncErrorHandler.alertPresenter = main + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = main + window?.makeKeyAndVisible() + + autoClear = AutoClear(worker: main) + let applicationState = application.applicationState + Task { + await autoClear?.clearDataIfEnabled(applicationState: .init(with: applicationState)) + await vpnWorkaround.installRedditSessionWorkaround() + } + } + + self.voiceSearchHelper.migrateSettingsFlagIfNecessary() + + // Task handler registration needs to happen before the end of `didFinishLaunching`, otherwise submitting a task can throw an exception. + // Having both in `didBecomeActive` can sometimes cause the exception when running on a physical device, so registration happens here. + AppConfigurationFetch.registerBackgroundRefreshTaskHandler() + + UNUserNotificationCenter.current().delegate = self + + window?.windowScene?.screenshotService?.delegate = self + ThemeManager.shared.updateUserInterfaceStyle(window: window) + + appIsLaunching = true + + // Temporary logic for rollout of Autofill as on by default for new installs only + if AppDependencyProvider.shared.appSettings.autofillIsNewInstallForOnByDefault == nil { + AppDependencyProvider.shared.appSettings.setAutofillIsNewInstallForOnByDefault() + } + + NewTabPageIntroMessageSetup().perform() + + widgetRefreshModel.beginObservingVPNStatus() + + AppDependencyProvider.shared.subscriptionManager.loadInitialData() + + setUpAutofillPixelReporter() + + if didCrashDuringCrashHandlersSetUp { + Pixel.fire(pixel: .crashOnCrashHandlersSetUp) + didCrashDuringCrashHandlersSetUp = false + } + + tipKitAppEventsHandler.appDidFinishLaunching() + + return true + } + + private func makeWebsiteDataManager(fireproofing: Fireproofing, + dataStoreIDManager: DataStoreIDManaging = DataStoreIDManager.shared) -> WebsiteDataManaging { + return WebCacheManager(cookieStorage: MigratableCookieStorage(), + fireproofing: fireproofing, + dataStoreIDManager: dataStoreIDManager) + } + + private func makeTextZoomCoordinator() -> TextZoomCoordinator { + let provider = AppDependencyProvider.shared + let storage = TextZoomStorage() + + return TextZoomCoordinator(appSettings: provider.appSettings, + storage: storage, + featureFlagger: provider.featureFlagger) + } + + private func makeSubscriptionCookieManager() -> SubscriptionCookieManaging { + let subscriptionCookieManager = SubscriptionCookieManager(subscriptionManager: AppDependencyProvider.shared.subscriptionManager, + currentCookieStore: { [weak self] in + guard self?.mainViewController?.tabManager.model.hasActiveTabs ?? false else { + // We shouldn't interact with WebKit's cookie store unless we have a WebView, + // eventually the subscription cookie will be refreshed on opening the first tab + return nil + } + + return WKHTTPCookieStoreWrapper(store: WKWebsiteDataStore.current().httpCookieStore) + }, eventMapping: SubscriptionCookieManageEventPixelMapping()) + + + let privacyConfigurationManager = ContentBlocking.shared.privacyConfigurationManager + + // Enable subscriptionCookieManager if feature flag is present + if privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) { + subscriptionCookieManager.enableSettingSubscriptionCookie() + } + + // Keep track of feature flag changes + subscriptionCookieManagerFeatureFlagCancellable = privacyConfigurationManager.updatesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self, weak privacyConfigurationManager] in + guard let self, !self.appIsLaunching, let privacyConfigurationManager else { return } + + let isEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) + + Task { @MainActor [weak self] in + if isEnabled { + self?.subscriptionCookieManager.enableSettingSubscriptionCookie() + } else { + await self?.subscriptionCookieManager.disableSettingSubscriptionCookie() + } + } + } + + return subscriptionCookieManager + } + + private func makeHistoryManager() -> HistoryManaging { + + let provider = AppDependencyProvider.shared + + switch HistoryManager.make(isAutocompleteEnabledByUser: provider.appSettings.autocomplete, + isRecentlyVisitedSitesEnabledByUser: provider.appSettings.recentlyVisitedSites, + privacyConfigManager: ContentBlocking.shared.privacyConfigurationManager, + tld: provider.storageCache.tld) { + + case .failure(let error): + Pixel.fire(pixel: .historyStoreLoadFailed, error: error) + if error.isDiskFull { + self.presentInsufficientDiskSpaceAlert() + } else { + self.presentPreemptiveCrashAlert() + } + return NullHistoryManager() + + case .success(let historyManager): + return historyManager + } + } + + private func prepareTabsModel(previewsSource: TabPreviewsSource = TabPreviewsSource(), + appSettings: AppSettings = AppDependencyProvider.shared.appSettings, + isDesktop: Bool = UIDevice.current.userInterfaceIdiom == .pad) -> TabsModel { + let isPadDevice = UIDevice.current.userInterfaceIdiom == .pad + let tabsModel: TabsModel + if AutoClearSettingsModel(settings: appSettings) != nil { + tabsModel = TabsModel(desktop: isPadDevice) + tabsModel.save() + previewsSource.removeAllPreviews() + } else { + if let storedModel = TabsModel.get() { + // Save new model in case of migration + storedModel.save() + tabsModel = storedModel + } else { + tabsModel = TabsModel(desktop: isPadDevice) + } + } + return tabsModel + } + + private func presentPreemptiveCrashAlert() { + Task { @MainActor in + let alertController = CriticalAlerts.makePreemptiveCrashAlert() + window?.rootViewController?.present(alertController, animated: true, completion: nil) + } + } + + private func presentInsufficientDiskSpaceAlert() { + let alertController = CriticalAlerts.makeInsufficientDiskSpaceAlert() + window?.rootViewController?.present(alertController, animated: true, completion: nil) + } + + private func presentExpiredEntitlementAlert() { + let alertController = CriticalAlerts.makeExpiredEntitlementAlert { [weak self] in + self?.mainViewController?.segueToPrivacyPro() + } + window?.rootViewController?.present(alertController, animated: true) { [weak self] in + self?.tunnelDefaults.showEntitlementAlert = false + } + } + + private func presentExpiredEntitlementNotificationIfNeeded() { + let presenter = NetworkProtectionNotificationsPresenterTogglableDecorator( + settings: AppDependencyProvider.shared.vpnSettings, + defaults: .networkProtectionGroupDefaults, + wrappee: NetworkProtectionUNNotificationPresenter() + ) + presenter.showEntitlementNotification() + } + + private func cleanUpMacPromoExperiment2() { + UserDefaults.standard.removeObject(forKey: "com.duckduckgo.ios.macPromoMay23.exp2.cohort") + } + + private func cleanUpIncrementalRolloutPixelTest() { + UserDefaults.standard.removeObject(forKey: "network-protection.incremental-feature-flag-test.has-sent-pixel") + } + + private func clearTmp() { + let tmp = FileManager.default.temporaryDirectory + do { + try FileManager.default.removeItem(at: tmp) + } catch { + Logger.general.error("Failed to delete tmp dir") + } + } + + private func reportAdAttribution() { + Task.detached(priority: .background) { + await AdAttributionPixelReporter.shared.reportAttributionIfNeeded() + } + } + + func applicationDidBecomeActive(_ application: UIApplication) { + guard !testing else { return } + + defer { + if let didFinishLaunchingStartTime { + let launchTime = CFAbsoluteTimeGetCurrent() - didFinishLaunchingStartTime + Pixel.fire(pixel: .appDidBecomeActiveTime(time: Pixel.Event.BucketAggregation(number: launchTime)), + withAdditionalParameters: [PixelParameters.time: String(launchTime)]) + } + } + + StorageInconsistencyMonitor().didBecomeActive(isProtectedDataAvailable: application.isProtectedDataAvailable) + syncService.initializeIfNeeded() + syncDataProviders.setUpDatabaseCleanersIfNeeded(syncService: syncService) + + if !(overlayWindow?.rootViewController is AuthenticationViewController) { + removeOverlay() + } + + StatisticsLoader.shared.load { + StatisticsLoader.shared.refreshAppRetentionAtb() + self.fireAppLaunchPixel() + self.reportAdAttribution() + self.onboardingPixelReporter.fireEnqueuedPixelsIfNeeded() + } + + if appIsLaunching { + appIsLaunching = false + onApplicationLaunch(application) + } + + mainViewController?.showBars() + mainViewController?.didReturnFromBackground() + + if !privacyStore.authenticationEnabled { + showKeyboardOnLaunch() + } + + if AppConfigurationFetch.shouldScheduleRulesCompilationOnAppLaunch { + ContentBlocking.shared.contentBlockingManager.scheduleCompilation() + AppConfigurationFetch.shouldScheduleRulesCompilationOnAppLaunch = false + } + AppDependencyProvider.shared.configurationManager.loadPrivacyConfigFromDiskIfNeeded() + + AppConfigurationFetch().start { result in + self.sendAppLaunchPostback() + if case .assetsUpdated(let protectionsUpdated) = result, protectionsUpdated { + ContentBlocking.shared.contentBlockingManager.scheduleCompilation() + } + } + + syncService.scheduler.notifyAppLifecycleEvent() + + privacyProDataReporter?.injectSyncService(syncService) + + fireFailedCompilationsPixelIfNeeded() + + widgetRefreshModel.refreshVPNWidget() + + if tunnelDefaults.showEntitlementAlert { + presentExpiredEntitlementAlert() + } + + presentExpiredEntitlementNotificationIfNeeded() + + Task { + await stopAndRemoveVPNIfNotAuthenticated() + await refreshShortcuts() + await vpnWorkaround.installRedditSessionWorkaround() + + if #available(iOS 17.0, *) { + await VPNSnoozeLiveActivityManager().endSnoozeActivityIfNecessary() + } + } + + AppDependencyProvider.shared.subscriptionManager.refreshCachedSubscriptionAndEntitlements { isSubscriptionActive in + if isSubscriptionActive { + DailyPixel.fire(pixel: .privacyProSubscriptionActive) + } + } + + Task { + await subscriptionCookieManager.refreshSubscriptionCookie() + } + + let importPasswordsStatusHandler = ImportPasswordsStatusHandler(syncService: syncService) + importPasswordsStatusHandler.checkSyncSuccessStatus() + + Task { + await privacyProDataReporter?.saveWidgetAdded() + } + + AppDependencyProvider.shared.persistentPixel.sendQueuedPixels { _ in } + } + + 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(reason: .didBecomeActiveCheck) + } + + func applicationWillResignActive(_ application: UIApplication) { + Task { @MainActor in + await refreshShortcuts() + await vpnWorkaround.removeRedditSessionWorkaround() + } + } + + private func fireAppLaunchPixel() { + + WidgetCenter.shared.getCurrentConfigurations { result in + let paramKeys: [WidgetFamily: String] = [ + .systemSmall: PixelParameters.widgetSmall, + .systemMedium: PixelParameters.widgetMedium, + .systemLarge: PixelParameters.widgetLarge + ] + + switch result { + case .failure(let error): + Pixel.fire(pixel: .appLaunch, withAdditionalParameters: [ + PixelParameters.widgetError: "1", + PixelParameters.widgetErrorCode: "\((error as NSError).code)", + PixelParameters.widgetErrorDomain: (error as NSError).domain + ], includedParameters: [.appVersion, .atb]) + + case .success(let widgetInfo): + let params = widgetInfo.reduce([String: String]()) { + var result = $0 + if let key = paramKeys[$1.family] { + result[key] = "1" + } + return result + } + Pixel.fire(pixel: .appLaunch, withAdditionalParameters: params, includedParameters: [.appVersion, .atb]) + } + + } + } + + private func fireFailedCompilationsPixelIfNeeded() { + let store = FailedCompilationsStore() + if store.hasAnyFailures { + DailyPixel.fire(pixel: .compilationFailed, withAdditionalParameters: store.summary) { error in + guard error != nil else { return } + store.cleanup() + } + } + } + + private func shouldShowKeyboardOnLaunch() -> Bool { + guard let date = lastBackgroundDate else { return true } + return Date().timeIntervalSince(date) > AppDelegate.ShowKeyboardOnLaunchThreshold + } + + private func showKeyboardOnLaunch() { + guard KeyboardSettings().onAppLaunch && showKeyboardIfSettingOn && shouldShowKeyboardOnLaunch() else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.mainViewController?.enterSearch() + } + showKeyboardIfSettingOn = false + } + + private func onApplicationLaunch(_ application: UIApplication) { + Task { @MainActor in + await beginAuthentication() + initialiseBackgroundFetch(application) + applyAppearanceChanges() + refreshRemoteMessages() + } + } + + private func applyAppearanceChanges() { + UILabel.appearance(whenContainedInInstancesOf: [UIAlertController.self]).numberOfLines = 0 + } + + /// It's public in order to allow refreshing on demand via Debug menu. Otherwise it shouldn't be called from outside. + func refreshRemoteMessages() { + Task { + try? await remoteMessagingClient.fetchAndProcess(using: remoteMessagingClient.store) + } + } + + func applicationWillEnterForeground(_ application: UIApplication) { + ThemeManager.shared.updateUserInterfaceStyle() + + Task { @MainActor in + await beginAuthentication() + await autoClear?.clearDataIfEnabledAndTimeExpired(applicationState: .active) + showKeyboardIfSettingOn = true + syncService.scheduler.resumeSyncQueue() + } + } + + func applicationDidEnterBackground(_ application: UIApplication) { + displayBlankSnapshotWindow() + autoClear?.startClearingTimer() + lastBackgroundDate = Date() + AppDependencyProvider.shared.autofillLoginSession.endSession() + suspendSync() + syncDataProviders.bookmarksAdapter.cancelFaviconsFetching(application) + privacyProDataReporter?.saveApplicationLastSessionEnded() + resetAppStartTime() + } + + private func resetAppStartTime() { + didFinishLaunchingStartTime = nil + mainViewController?.appDidFinishLaunchingStartTime = nil + } + + private func suspendSync() { + if syncService.isSyncInProgress { + Logger.sync.debug("Sync is in progress. Starting background task to allow it to gracefully complete.") + + var taskID: UIBackgroundTaskIdentifier! + taskID = UIApplication.shared.beginBackgroundTask(withName: "Cancelled Sync Completion Task") { + Logger.sync.debug("Forcing background task completion") + UIApplication.shared.endBackgroundTask(taskID) + } + syncDidFinishCancellable?.cancel() + syncDidFinishCancellable = syncService.isSyncInProgressPublisher.filter { !$0 } + .prefix(1) + .receive(on: DispatchQueue.main) + .sink { _ in + Logger.sync.debug("Ending background task") + UIApplication.shared.endBackgroundTask(taskID) + } + } + + syncService.scheduler.cancelSyncAndSuspendSyncQueue() + } + + func application(_ application: UIApplication, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void) { + handleShortCutItem(shortcutItem) + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + Logger.sync.debug("App launched with url \(url.absoluteString)") + + // If showing the onboarding intro ignore deeplinks + guard mainViewController?.needsToShowOnboardingIntro() == false else { + return false + } + + if handleEmailSignUpDeepLink(url) { + return true + } + + NotificationCenter.default.post(name: AutofillLoginListAuthenticator.Notifications.invalidateContext, object: nil) + + // The openVPN action handles the navigation stack on its own and does not need it to be cleared + if url != AppDeepLinkSchemes.openVPN.url { + mainViewController?.clearNavigationStack() + } + + Task { @MainActor in + // Autoclear should have happened by now + showKeyboardIfSettingOn = false + + if !handleAppDeepLink(app, mainViewController, url) { + mainViewController?.loadUrlInNewTab(url, reuseExisting: true, inheritedAttribution: nil, fromExternalLink: true) + } + } + + return true + } + + func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + + Logger.lifecycle.debug(#function) + + AppConfigurationFetch().start(isBackgroundFetch: true) { result in + switch result { + case .noData: + completionHandler(.noData) + case .assetsUpdated: + completionHandler(.newData) + } + } + } + + func application(_ application: UIApplication, willContinueUserActivityWithType userActivityType: String) -> Bool { + return true + } + + // MARK: private + + private func sendAppLaunchPostback() { + // Attribution support + let privacyConfigurationManager = ContentBlocking.shared.privacyConfigurationManager + if privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .marketplaceAdPostback) { + marketplaceAdPostbackManager.sendAppLaunchPostback() + } + } + + private func cleanUpATBAndAssignVariant(variantManager: VariantManager, daxDialogs: DaxDialogs) { + let historyMessageManager = HistoryMessageManager() + + AtbAndVariantCleanup.cleanup() + variantManager.assignVariantIfNeeded { _ in + // MARK: perform first time launch logic here + // If it's running UI Tests check if the onboarding should be in a completed state. + if launchOptionsHandler.isUITesting && launchOptionsHandler.isOnboardingCompleted { + daxDialogs.dismiss() + } else { + daxDialogs.primeForUse() + } + + // New users don't see the message + historyMessageManager.dismiss() + + // Setup storage for marketplace postback + marketplaceAdPostbackManager.updateReturningUserValue() + } + } + + private func initialiseBackgroundFetch(_ application: UIApplication) { + guard UIApplication.shared.backgroundRefreshStatus == .available else { + return + } + + // BackgroundTasks will automatically replace an existing task in the queue if one with the same identifier is queued, so we should only + // schedule a task if there are none pending in order to avoid the config task getting perpetually replaced. + BGTaskScheduler.shared.getPendingTaskRequests { tasks in + let hasConfigurationTask = tasks.contains { $0.identifier == AppConfigurationFetch.Constants.backgroundProcessingTaskIdentifier } + if !hasConfigurationTask { + AppConfigurationFetch.scheduleBackgroundRefreshTask() + } + + let hasRemoteMessageFetchTask = tasks.contains { $0.identifier == RemoteMessagingClient.Constants.backgroundRefreshTaskIdentifier } + if !hasRemoteMessageFetchTask { + RemoteMessagingClient.scheduleBackgroundRefreshTask() + } + } + } + + private func displayAuthenticationWindow() { + guard overlayWindow == nil, let frame = window?.frame else { return } + overlayWindow = UIWindow(frame: frame) + overlayWindow?.windowLevel = UIWindow.Level.alert + overlayWindow?.rootViewController = AuthenticationViewController.loadFromStoryboard() + overlayWindow?.makeKeyAndVisible() + window?.isHidden = true + } + + private func displayBlankSnapshotWindow() { + guard overlayWindow == nil, let frame = window?.frame else { return } + guard autoClear?.isClearingEnabled ?? false || privacyStore.authenticationEnabled else { return } + + overlayWindow = UIWindow(frame: frame) + overlayWindow?.windowLevel = UIWindow.Level.alert + + let overlay = BlankSnapshotViewController(addressBarPosition: AppDependencyProvider.shared.appSettings.currentAddressBarPosition, + voiceSearchHelper: voiceSearchHelper) + overlay.delegate = self + + overlayWindow?.rootViewController = overlay + overlayWindow?.makeKeyAndVisible() + window?.isHidden = true + } + + private func beginAuthentication() async { + + guard privacyStore.authenticationEnabled else { return } + + removeOverlay() + displayAuthenticationWindow() + + guard let controller = overlayWindow?.rootViewController as? AuthenticationViewController else { + removeOverlay() + return + } + + await controller.beginAuthentication { [weak self] in + self?.removeOverlay() + self?.showKeyboardOnLaunch() + } + } + + private func tryToObtainOverlayWindow() { + for window in UIApplication.shared.foregroundSceneWindows where window.rootViewController is BlankSnapshotViewController { + overlayWindow = window + return + } + } + + private func removeOverlay() { + if overlayWindow == nil { + tryToObtainOverlayWindow() + } + + if let overlay = overlayWindow { + overlay.isHidden = true + overlayWindow = nil + window?.makeKeyAndVisible() + } + } + + private func handleShortCutItem(_ shortcutItem: UIApplicationShortcutItem) { + Logger.general.debug("Handling shortcut item: \(shortcutItem.type)") + + Task { @MainActor in + + if appIsLaunching { + await autoClear?.clearDataIfEnabled() + } else { + await autoClear?.clearDataIfEnabledAndTimeExpired(applicationState: .active) + } + + if shortcutItem.type == AppDelegate.ShortcutKey.clipboard, let query = UIPasteboard.general.string { + mainViewController?.clearNavigationStack() + mainViewController?.loadQueryInNewTab(query) + return + } + + if shortcutItem.type == AppDelegate.ShortcutKey.passwords { + mainViewController?.clearNavigationStack() + // Give the `clearNavigationStack` call time to complete. + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) { [weak self] in + self?.mainViewController?.launchAutofillLogins(openSearch: true, source: .appIconShortcut) + } + Pixel.fire(pixel: .autofillLoginsLaunchAppShortcut) + return + } + + if shortcutItem.type == AppDelegate.ShortcutKey.openVPNSettings { + presentNetworkProtectionStatusSettingsModal() + } + + } + } + + private func removeEmailWaitlistState() { + EmailWaitlist.removeEmailState() + + let autofillStorage = EmailKeychainManager() + try? autofillStorage.deleteWaitlistState() + + // Remove the authentication state if this is a fresh install. + if !Database.shared.isDatabaseFileInitialized { + try? autofillStorage.deleteAuthenticationState() + } + } + + private func handleEmailSignUpDeepLink(_ url: URL) -> Bool { + guard url.absoluteString.starts(with: URL.emailProtection.absoluteString), + let navViewController = mainViewController?.presentedViewController as? UINavigationController, + let emailSignUpViewController = navViewController.topViewController as? EmailSignupViewController else { + return false + } + emailSignUpViewController.loadUrl(url) + return true + } + + private var mainViewController: MainViewController? { + return window?.rootViewController as? MainViewController + } + + private func setUpAutofillPixelReporter() { + autofillPixelReporter = AutofillPixelReporter( + userDefaults: .standard, + autofillEnabled: AppDependencyProvider.shared.appSettings.autofillCredentialsEnabled, + eventMapping: EventMapping {[weak self] event, _, params, _ in + switch event { + case .autofillActiveUser: + Pixel.fire(pixel: .autofillActiveUser) + case .autofillEnabledUser: + Pixel.fire(pixel: .autofillEnabledUser) + case .autofillOnboardedUser: + Pixel.fire(pixel: .autofillOnboardedUser) + case .autofillToggledOn: + Pixel.fire(pixel: .autofillToggledOn, withAdditionalParameters: params ?? [:]) + if let autofillExtensionToggled = self?.autofillUsageMonitor.autofillExtensionEnabled { + Pixel.fire(pixel: autofillExtensionToggled ? .autofillExtensionToggledOn : .autofillExtensionToggledOff, + withAdditionalParameters: params ?? [:]) + } + case .autofillToggledOff: + Pixel.fire(pixel: .autofillToggledOff, withAdditionalParameters: params ?? [:]) + if let autofillExtensionToggled = self?.autofillUsageMonitor.autofillExtensionEnabled { + Pixel.fire(pixel: autofillExtensionToggled ? .autofillExtensionToggledOn : .autofillExtensionToggledOff, + withAdditionalParameters: params ?? [:]) + } + case .autofillLoginsStacked: + Pixel.fire(pixel: .autofillLoginsStacked, withAdditionalParameters: params ?? [:]) + default: + break + } + }, + installDate: StatisticsUserDefaults().installDate ?? Date()) + + _ = NotificationCenter.default.addObserver(forName: AppUserDefaults.Notifications.autofillEnabledChange, + object: nil, + queue: nil) { [weak self] _ in + self?.autofillPixelReporter?.updateAutofillEnabledStatus(AppDependencyProvider.shared.appSettings.autofillCredentialsEnabled) + } + } + + @MainActor + func refreshShortcuts() async { + guard AppDependencyProvider.shared.vpnFeatureVisibility.shouldShowVPNShortcut() else { + UIApplication.shared.shortcutItems = nil + return + } + + if case .success(true) = await accountManager.hasEntitlement(forProductName: .networkProtection, cachePolicy: .returnCacheDataDontLoad) { + let items = [ + UIApplicationShortcutItem(type: AppDelegate.ShortcutKey.openVPNSettings, + localizedTitle: UserText.netPOpenVPNQuickAction, + localizedSubtitle: nil, + icon: UIApplicationShortcutIcon(templateImageName: "VPN-16"), + userInfo: nil) + ] + + UIApplication.shared.shortcutItems = items + } else { + UIApplication.shared.shortcutItems = nil + } + } +} + + +extension OldAppDelegate: BlankSnapshotViewRecoveringDelegate { + + func recoverFromPresenting(controller: BlankSnapshotViewController) { + if overlayWindow == nil { + tryToObtainOverlayWindow() + } + + overlayWindow?.isHidden = true + overlayWindow = nil + window?.makeKeyAndVisible() + } + +} + +extension OldAppDelegate: UIScreenshotServiceDelegate { + func screenshotService(_ screenshotService: UIScreenshotService, + generatePDFRepresentationWithCompletion completionHandler: @escaping (Data?, Int, CGRect) -> Void) { + guard let webView = mainViewController?.currentTab?.webView else { + completionHandler(nil, 0, .zero) + return + } + + let zoomScale = webView.scrollView.zoomScale + + // The PDF's coordinate space has its origin at the bottom left, so the view's origin.y needs to be converted + let visibleBounds = CGRect( + x: webView.scrollView.contentOffset.x / zoomScale, + y: (webView.scrollView.contentSize.height - webView.scrollView.contentOffset.y - webView.bounds.height) / zoomScale, + width: webView.bounds.width / zoomScale, + height: webView.bounds.height / zoomScale + ) + + webView.createPDF { result in + let data = try? result.get() + completionHandler(data, 0, visibleBounds) + } + } +} + +extension OldAppDelegate: UNUserNotificationCenterDelegate { + + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler(.banner) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + if response.actionIdentifier == UNNotificationDefaultActionIdentifier { + let identifier = response.notification.request.identifier + + if NetworkProtectionNotificationIdentifier(rawValue: identifier) != nil { + presentNetworkProtectionStatusSettingsModal() + } + } + + completionHandler() + } + + func presentNetworkProtectionStatusSettingsModal() { + Task { + if case .success(let hasEntitlements) = await accountManager.hasEntitlement(forProductName: .networkProtection), hasEntitlements { + (window?.rootViewController as? MainViewController)?.segueToVPN() + } else { + (window?.rootViewController as? MainViewController)?.segueToPrivacyPro() + } + } + } + + private func presentSettings(with viewController: UIViewController) { + guard let window = window, let rootViewController = window.rootViewController as? MainViewController else { return } + + if let navigationController = rootViewController.presentedViewController as? UINavigationController { + if let lastViewController = navigationController.viewControllers.last, lastViewController.isKind(of: type(of: viewController)) { + // Avoid presenting dismissing and re-presenting the view controller if it's already visible: + return + } else { + // Otherwise, replace existing view controllers with the presented one: + navigationController.popToRootViewController(animated: false) + navigationController.pushViewController(viewController, animated: false) + return + } + } + + // If the previous checks failed, make sure the nav stack is reset and present the view controller from scratch: + rootViewController.clearNavigationStack() + + // Give the `clearNavigationStack` call time to complete. + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) { + rootViewController.segueToSettings() + let navigationController = rootViewController.presentedViewController as? UINavigationController + navigationController?.popToRootViewController(animated: false) + navigationController?.pushViewController(viewController, animated: false) + } + } +} diff --git a/DuckDuckGoTests/AppSettingsMock.swift b/DuckDuckGoTests/AppSettingsMock.swift index 13ced3eb65..2aea19af93 100644 --- a/DuckDuckGoTests/AppSettingsMock.swift +++ b/DuckDuckGoTests/AppSettingsMock.swift @@ -22,6 +22,8 @@ import Foundation @testable import DuckDuckGo class AppSettingsMock: AppSettings { + var appBehavior: DuckDuckGo.AppBehavior? = .new + var defaultTextZoomLevel: DuckDuckGo.TextZoomLevel = .percent100 var recentlyVisitedSites: Bool = false