diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index 15263d9e7b..8bf183dafd 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -56,6 +56,7 @@ public enum FeatureFlag: String { /// https://app.asana.com/0/1208592102886666/1208613627589762/f case crashReportOptInStatusResetting + case isPrivacyProLaunchedROW case isPrivacyProLaunchedROWOverride diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index b98773df96..48d2f36ac4 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -27,7 +27,8 @@ import NetworkProtection extension Pixel { public enum Event { - + + case appInstall case appLaunch case refreshPressed case pullToRefresh @@ -80,6 +81,7 @@ extension Pixel { case browsingMenuShare case browsingMenuCopy case browsingMenuPrint + case browsingMenuListPrint case browsingMenuFindInPage case browsingMenuZoom case browsingMenuDisableProtection @@ -87,7 +89,8 @@ extension Pixel { case browsingMenuReportBrokenSite case browsingMenuFireproof case browsingMenuAutofill - + case browsingMenuAIChat + case addressBarShare case addressBarSettings case addressBarCancelPressedOnNTP @@ -895,9 +898,13 @@ extension Pixel { case appDidShowUITime(time: BucketAggregation) case appDidBecomeActiveTime(time: BucketAggregation) + // MARK: AI Chat + case openAIChatBefore10min + case openAIChatAfter10min + case aiChatNoRemoteSettingsFound(settings: String) + // MARK: Lifecycle case appDidTransitionToUnexpectedState - } } @@ -908,6 +915,7 @@ extension Pixel.Event { public var name: String { switch self { + case .appInstall: return "m_install" case .appLaunch: return "ml" case .refreshPressed: return "m_r" case .pullToRefresh: return "m_pull-to-reload" @@ -959,6 +967,7 @@ extension Pixel.Event { case .browsingMenuToggleBrowsingMode: return "mb_dm" case .browsingMenuCopy: return "mb_cp" case .browsingMenuPrint: return "mb_pr" + case .browsingMenuFindInPage: return "mb_fp" case .browsingMenuZoom: return "m_menu_page_zoom_taps" case .browsingMenuDisableProtection: return "mb_wla" @@ -968,6 +977,8 @@ extension Pixel.Event { case .browsingMenuAutofill: return "m_nav_autofill_menu_item_pressed" case .browsingMenuShare: return "m_browsingmenu_share" + case .browsingMenuAIChat: return "m_aichat_menu_tab_icon" + case .browsingMenuListPrint: return "m_browsing_menu_list_print" case .addressBarShare: return "m_addressbar_share" case .addressBarSettings: return "m_addressbar_settings" @@ -1787,6 +1798,12 @@ extension Pixel.Event { case .appDidShowUITime(let time): return "m_debug_app-did-show-ui-time-\(time)" case .appDidBecomeActiveTime(let time): return "m_debug_app-did-become-active-time-\(time)" + // MARK: AI Chat + case .openAIChatAfter10min: return "m_aichat_open_after_10_min" + case .openAIChatBefore10min: return "m_aichat_open_before_10_min" + case .aiChatNoRemoteSettingsFound(let settings): + return "m_aichat_no_remote_settings_found-\(settings.lowercased())" + // MARK: Lifecycle case .appDidTransitionToUnexpectedState: return "m_debug_app-did-transition-to-unexpected-state" diff --git a/Core/StatisticsLoader.swift b/Core/StatisticsLoader.swift index a8001e6077..7d60430add 100644 --- a/Core/StatisticsLoader.swift +++ b/Core/StatisticsLoader.swift @@ -35,15 +35,18 @@ public class StatisticsLoader { private let parser = AtbParser() private let atbPresenceFileMarker = BoolFileMarker(name: .isATBPresent) private let inconsistencyMonitoring: StatisticsStoreInconsistencyMonitoring + private let pixelFiring: PixelFiring.Type init(statisticsStore: StatisticsStore = StatisticsUserDefaults(), returnUserMeasurement: ReturnUserMeasurement = KeychainReturnUserMeasurement(), usageSegmentation: UsageSegmenting = UsageSegmentation(), - inconsistencyMonitoring: StatisticsStoreInconsistencyMonitoring = StorageInconsistencyMonitor()) { + inconsistencyMonitoring: StatisticsStoreInconsistencyMonitoring = StorageInconsistencyMonitor(), + pixelFiring: PixelFiring.Type = Pixel.self) { self.statisticsStore = statisticsStore self.returnUserMeasurement = returnUserMeasurement self.usageSegmentation = usageSegmentation self.inconsistencyMonitoring = inconsistencyMonitoring + self.pixelFiring = pixelFiring } public func load(completion: @escaping Completion = {}) { @@ -94,6 +97,7 @@ public class StatisticsLoader { completion() return } + self.fireInstallPixel() self.statisticsStore.installDate = Date() self.statisticsStore.atb = atb.version self.returnUserMeasurement.installCompletedWithATB(atb) @@ -102,6 +106,20 @@ public class StatisticsLoader { } } + private func fireInstallPixel() { + let formattedLocale = Locale.current.localeIdentifierAsJsonFormat + let isReinstall = String(statisticsStore.variant == VariantIOS.returningUser.name) + let parameters = [ + "locale": formattedLocale, + "reinstall": isReinstall + ] + pixelFiring.fire(.appInstall, withAdditionalParameters: parameters, includedParameters: [.appVersion], onComplete: { error in + if let error { + Logger.general.error("Install pixel failed with error: \(error.localizedDescription, privacy: .public)") + } + }) + } + private func createATBFileMarker() { atbPresenceFileMarker?.mark() } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 453e70f3f0..d0119223ce 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -128,6 +128,7 @@ 1EFDCBC127D2393C00916BC5 /* DownloadsDeleteHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFDCBC027D2393C00916BC5 /* DownloadsDeleteHelper.swift */; }; 22CB1ED8203DDD2C00D2C724 /* AppDeepLinksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22CB1ED7203DDD2C00D2C724 /* AppDeepLinksTests.swift */; }; 2DC3FC65C6D9DA634426672D /* AutofillNoAuthAvailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DC3FBD62FBAF21E87610FA8 /* AutofillNoAuthAvailableView.swift */; }; + 31043B162CFA5B8E0028A97F /* AIChatPixelHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31043B152CFA5B890028A97F /* AIChatPixelHandler.swift */; }; 310742A62848CD780012660B /* BackForwardMenuHistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310742A52848CD780012660B /* BackForwardMenuHistoryItem.swift */; }; 310742AB2848E6FD0012660B /* BackForwardMenuHistoryItemURLSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310742A92848E5B70012660B /* BackForwardMenuHistoryItemURLSanitizerTests.swift */; }; 310C4B45281B5A9A00BA79A9 /* AutofillLoginDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310C4B44281B5A9A00BA79A9 /* AutofillLoginDetailsView.swift */; }; @@ -137,6 +138,7 @@ 310D09212799FD1A00DC0060 /* MIMEType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310D09202799FD1A00DC0060 /* MIMEType.swift */; }; 310E79BD2949CAA5007C49E8 /* FireButtonReferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310E79BC2949CAA5007C49E8 /* FireButtonReferenceTests.swift */; }; 310ECFDD282A8BB0005029B3 /* EnableAutofillSettingsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310ECFDC282A8BB0005029B3 /* EnableAutofillSettingsTableViewCell.swift */; }; + 310EEA2F2CFFCDC60043CA1A /* AIChatSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310EEA2E2CFFCDBF0043CA1A /* AIChatSettingsTests.swift */; }; 311BD1AD2836BB3900AEF6C1 /* AutofillItemsEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311BD1AC2836BB3900AEF6C1 /* AutofillItemsEmptyView.swift */; }; 311BD1AF2836BB4200AEF6C1 /* AutofillItemsLockedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311BD1AE2836BB4200AEF6C1 /* AutofillItemsLockedView.swift */; }; 311BD1B12836C0CA00AEF6C1 /* AutofillLoginListAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311BD1B02836C0CA00AEF6C1 /* AutofillLoginListAuthenticator.swift */; }; @@ -157,13 +159,16 @@ 3157B43827F4C8490042D3D7 /* FaviconsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3157B43727F4C8490042D3D7 /* FaviconsHelper.swift */; }; 31584616281AFB46004ADB8B /* AutofillLoginDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31584615281AFB46004ADB8B /* AutofillLoginDetailsViewController.swift */; }; 3158461A281B08F5004ADB8B /* AutofillLoginListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31584619281B08F5004ADB8B /* AutofillLoginListViewModel.swift */; }; + 315C77822CFA41A400699683 /* AIChat in Frameworks */ = {isa = PBXBuildFile; productRef = 315C77812CFA41A400699683 /* AIChat */; }; 3161D13227AC161B00285CF6 /* DownloadMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3161D13127AC161B00285CF6 /* DownloadMetadata.swift */; }; 31669B9A28020A460071CC18 /* SaveLoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31669B9928020A460071CC18 /* SaveLoginViewModel.swift */; }; 316790E52C9352190090B0A2 /* MarketplaceAdPostbackManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316790E42C9352190090B0A2 /* MarketplaceAdPostbackManagerTests.swift */; }; 316931D727BD10BB0095F5ED /* SaveToDownloadsAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316931D627BD10BB0095F5ED /* SaveToDownloadsAlert.swift */; }; 316931D927BD22A80095F5ED /* DownloadActionMessageViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316931D827BD22A80095F5ED /* DownloadActionMessageViewHelper.swift */; }; + 316AA45A2CF8E31F00A2ED28 /* AIChatSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA4592CF8E31F00A2ED28 /* AIChatSettings.swift */; }; 3170048227A9504F00C03F35 /* DownloadMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3170048127A9504F00C03F35 /* DownloadMocks.swift */; }; 317045C02858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317045BF2858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift */; }; + 317CA3432CFF82E100F88848 /* SettingsAIChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317CA3422CFF82DB00F88848 /* SettingsAIChatView.swift */; }; 317F5F982C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317F5F972C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift */; }; 31860A5B2C57ED2D005561F5 /* DuckPlayerStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */; }; 31951E8E2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31951E8D2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift */; }; @@ -1488,6 +1493,7 @@ 1EFDCBC027D2393C00916BC5 /* DownloadsDeleteHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsDeleteHelper.swift; sourceTree = ""; }; 22CB1ED7203DDD2C00D2C724 /* AppDeepLinksTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDeepLinksTests.swift; sourceTree = ""; }; 2DC3FBD62FBAF21E87610FA8 /* AutofillNoAuthAvailableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillNoAuthAvailableView.swift; sourceTree = ""; }; + 31043B152CFA5B890028A97F /* AIChatPixelHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatPixelHandler.swift; sourceTree = ""; }; 310742A52848CD780012660B /* BackForwardMenuHistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackForwardMenuHistoryItem.swift; sourceTree = ""; }; 310742A92848E5B70012660B /* BackForwardMenuHistoryItemURLSanitizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackForwardMenuHistoryItemURLSanitizerTests.swift; sourceTree = ""; }; 310C4B44281B5A9A00BA79A9 /* AutofillLoginDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginDetailsView.swift; sourceTree = ""; }; @@ -1497,6 +1503,7 @@ 310D09202799FD1A00DC0060 /* MIMEType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MIMEType.swift; sourceTree = ""; }; 310E79BC2949CAA5007C49E8 /* FireButtonReferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FireButtonReferenceTests.swift; sourceTree = ""; }; 310ECFDC282A8BB0005029B3 /* EnableAutofillSettingsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnableAutofillSettingsTableViewCell.swift; sourceTree = ""; }; + 310EEA2E2CFFCDBF0043CA1A /* AIChatSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatSettingsTests.swift; sourceTree = ""; }; 311BD1AC2836BB3900AEF6C1 /* AutofillItemsEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillItemsEmptyView.swift; sourceTree = ""; }; 311BD1AE2836BB4200AEF6C1 /* AutofillItemsLockedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillItemsLockedView.swift; sourceTree = ""; }; 311BD1B02836C0CA00AEF6C1 /* AutofillLoginListAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginListAuthenticator.swift; sourceTree = ""; }; @@ -1517,14 +1524,17 @@ 3157B43727F4C8490042D3D7 /* FaviconsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconsHelper.swift; sourceTree = ""; }; 31584615281AFB46004ADB8B /* AutofillLoginDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginDetailsViewController.swift; sourceTree = ""; }; 31584619281B08F5004ADB8B /* AutofillLoginListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginListViewModel.swift; sourceTree = ""; }; + 315C77802CFA414400699683 /* AIChat */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AIChat; sourceTree = ""; }; 3161D13127AC161B00285CF6 /* DownloadMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadMetadata.swift; sourceTree = ""; }; 31669B9928020A460071CC18 /* SaveLoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveLoginViewModel.swift; sourceTree = ""; }; 316790E42C9352190090B0A2 /* MarketplaceAdPostbackManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketplaceAdPostbackManagerTests.swift; sourceTree = ""; }; 316931D627BD10BB0095F5ED /* SaveToDownloadsAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToDownloadsAlert.swift; sourceTree = ""; }; 316931D827BD22A80095F5ED /* DownloadActionMessageViewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadActionMessageViewHelper.swift; sourceTree = ""; }; + 316AA4592CF8E31F00A2ED28 /* AIChatSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatSettings.swift; sourceTree = ""; }; 3170048127A9504F00C03F35 /* DownloadMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadMocks.swift; sourceTree = ""; }; 317045BF2858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillInterfaceEmailTruncatorTests.swift; sourceTree = ""; }; 31794BFF2821DFB600F18633 /* DuckUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = DuckUI; sourceTree = ""; }; + 317CA3422CFF82DB00F88848 /* SettingsAIChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAIChatView.swift; sourceTree = ""; }; 317F5F972C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketplaceAdPostbackStorage.swift; sourceTree = ""; }; 31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerStorage.swift; sourceTree = ""; }; 31951E8D2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginDetailsHeaderView.swift; sourceTree = ""; }; @@ -3127,6 +3137,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 315C77822CFA41A400699683 /* AIChat in Frameworks */, 9F8FE9492BAE50E50071E372 /* Lottie in Frameworks */, 853273B624FFE0BB00E3C778 /* WidgetKit.framework in Frameworks */, 0238E44F29C0FAA100615E30 /* FindInPageIOSJSSupport in Frameworks */, @@ -3341,6 +3352,7 @@ 1DEAADEB2BA45B4400E25A97 /* SettingsAccessibilityView.swift */, 1DEAADED2BA45DFE00E25A97 /* SettingsDataClearingView.swift */, D65625A02C232F5E006EF297 /* SettingsDuckPlayerView.swift */, + 317CA3422CFF82DB00F88848 /* SettingsAIChatView.swift */, ); name = MainSettings; sourceTree = ""; @@ -3588,6 +3600,30 @@ name = Downloads; sourceTree = ""; }; + 310EEA2C2CFFCD9B0043CA1A /* New Group */ = { + isa = PBXGroup; + children = ( + ); + path = "New Group"; + sourceTree = ""; + }; + 310EEA2D2CFFCDB60043CA1A /* AIChat */ = { + isa = PBXGroup; + children = ( + 310EEA2E2CFFCDBF0043CA1A /* AIChatSettingsTests.swift */, + ); + path = AIChat; + sourceTree = ""; + }; + 311C79E22CF790270021196A /* AIChat */ = { + isa = PBXGroup; + children = ( + 31043B152CFA5B890028A97F /* AIChatPixelHandler.swift */, + 316AA4592CF8E31F00A2ED28 /* AIChatSettings.swift */, + ); + path = AIChat; + sourceTree = ""; + }; 3132FA2227A0776B00DD7A12 /* FilePreview */ = { isa = PBXGroup; children = ( @@ -3768,6 +3804,7 @@ 85875B5F29912A2D00115F05 /* SyncUI */, 37FCAACB2993149A000E420A /* Waitlist */, 31794BFF2821DFB600F18633 /* DuckUI */, + 315C77802CFA414400699683 /* AIChat */, ); path = LocalPackages; sourceTree = ""; @@ -4309,6 +4346,7 @@ 84E341941E2F7EFB00BDBA6F /* DuckDuckGo */ = { isa = PBXGroup; children = ( + 311C79E22CF790270021196A /* AIChat */, 6FD1BAE02B87A0E8000C475C /* AdAttribution */, AA4D6A8023DE4973007E8790 /* AppIcon */, F1C5ECF31E37812900C599A4 /* Application */, @@ -6523,6 +6561,7 @@ F1E092B31E92A6B900732CCC /* Core */ = { isa = PBXGroup; children = ( + 310EEA2C2CFFCD9B0043CA1A /* New Group */, 316790E32C9350980090B0A2 /* MarketplaceAdPostback */, 858479CA2B8795BF00D156C1 /* History */, EA7EFE662677F5BD0075464E /* PrivacyReferenceTests */, @@ -6533,6 +6572,7 @@ EE3B226929DE0EE10082298A /* FeatureFlags */, F1134EC91F40E74800B73467 /* Statistics */, F198D78F1E3976300088DA8A /* Utilities */, + 310EEA2D2CFFCDB60043CA1A /* AIChat */, ); name = Core; sourceTree = ""; @@ -6736,6 +6776,7 @@ 9F8FE9482BAE50E50071E372 /* Lottie */, 9F96F73A2C9144D5009E45D5 /* Onboarding */, 1E5918462CA422A7008ED2B3 /* Navigation */, + 315C77812CFA41A400699683 /* AIChat */, ); productName = DuckDuckGo; productReference = 84E341921E2F7EFB00BDBA6F /* DuckDuckGo.app */; @@ -7801,6 +7842,7 @@ D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */, 85F2FFCF2211F8E5006BB258 /* TabSwitcherViewController+KeyCommands.swift in Sources */, 3157B43327F497E90042D3D7 /* SaveLoginView.swift in Sources */, + 316AA45A2CF8E31F00A2ED28 /* AIChatSettings.swift in Sources */, F17922E01E71BB59006E3D97 /* AutocompleteViewControllerDelegate.swift in Sources */, BDE91CDC2C62AA3A0005CB74 /* DefaultMetadataCollector.swift in Sources */, D664C7C82B289AA200CBFA76 /* SubscriptionFlowView.swift in Sources */, @@ -7980,6 +8022,7 @@ 9FE05CEE2C36424E00D9046B /* OnboardingPixelReporter.swift in Sources */, 9821234E2B6D0A6300F08C57 /* UserAuthenticator.swift in Sources */, 310C4B47281B60E300BA79A9 /* AutofillLoginDetailsViewModel.swift in Sources */, + 31043B162CFA5B8E0028A97F /* AIChatPixelHandler.swift in Sources */, 85EE7F572246685B000FE757 /* WebContainerViewController.swift in Sources */, CB48D3332B90CE9F00631D8B /* PageRefreshStore.swift in Sources */, 1EC458462948932500CB2B13 /* UIHostingControllerExtension.swift in Sources */, @@ -8111,6 +8154,7 @@ 8590CB67268A2E520089F6BF /* RootDebugViewController.swift in Sources */, 1DEAADEA2BA4539800E25A97 /* SettingsAppearanceView.swift in Sources */, B623C1C22862CA9E0043013E /* DownloadSession.swift in Sources */, + 317CA3432CFF82E100F88848 /* SettingsAIChatView.swift in Sources */, 9F7CFF7F2C8A94F70012833E /* OnboardingView+AddressBarPositionContent.swift in Sources */, 985892522260B1B200EEB31B /* ProgressView.swift in Sources */, 85BA585A1F3506AE00C6E8CA /* AppSettings.swift in Sources */, @@ -8313,6 +8357,7 @@ 987130C8294AAB9F00AB05E0 /* BookmarksTestHelpers.swift in Sources */, 9F4CC51D2C48D240006A96EB /* CoreDataDatabaseTestUtilities.swift in Sources */, C185ED672BD43DA100BAE9DC /* ImportPasswordsStatusHandlerTests.swift in Sources */, + 310EEA2F2CFFCDC60043CA1A /* AIChatSettingsTests.swift in Sources */, 6FF9AD452CE766F700C5A406 /* NewTabPageControllerPixelTests.swift in Sources */, 85C503FD2CF0E7B10075DF6F /* MockFireproofing.swift in Sources */, 6F3529FF2CDCEDFF00A59170 /* OmniBarLoadingStateBearerTests.swift in Sources */, @@ -9431,7 +9476,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -9468,7 +9513,7 @@ CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9558,7 +9603,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9585,7 +9630,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9731,7 +9776,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9755,7 +9800,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; INFOPLIST_FILE = DuckDuckGo/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9822,7 +9867,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -9856,7 +9901,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9889,7 +9934,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9919,7 +9964,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10308,7 +10353,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10339,7 +10384,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10367,7 +10412,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10400,7 +10445,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -10430,7 +10475,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProviderAlpha.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -10463,11 +10508,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10697,7 +10742,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10725,7 +10770,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10757,7 +10802,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10794,7 +10839,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10829,7 +10874,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10864,11 +10909,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -11040,11 +11085,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -11073,10 +11118,10 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 2; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -11426,6 +11471,10 @@ package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = Common; }; + 315C77812CFA41A400699683 /* AIChat */ = { + isa = XCSwiftPackageProductDependency; + productName = AIChat; + }; 31E69A62280F4CB600478327 /* DuckUI */ = { isa = XCSwiftPackageProductDependency; productName = DuckUI; diff --git a/DuckDuckGo/AIChat/AIChatPixelHandler.swift b/DuckDuckGo/AIChat/AIChatPixelHandler.swift new file mode 100644 index 0000000000..b4e1ccd4b5 --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatPixelHandler.swift @@ -0,0 +1,32 @@ +// +// AIChatPixelHandler.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 AIChat +import Core + +struct AIChatPixelHandler: AIChatPixelHandling { + func fire(pixel: AIChatPixel) { + switch pixel { + case .openAfter10min: + Pixel.fire(pixel: .openAIChatAfter10min) + case .openBefore10min: + Pixel.fire(pixel: .openAIChatBefore10min) + } + } +} diff --git a/DuckDuckGo/AIChat/AIChatSettings.swift b/DuckDuckGo/AIChat/AIChatSettings.swift new file mode 100644 index 0000000000..6a0b1e3706 --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatSettings.swift @@ -0,0 +1,108 @@ +// +// AIChatSettings.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 BrowserServicesKit +import AIChat +import Foundation +import Core + +/// This struct serves as a wrapper for PrivacyConfigurationManaging, enabling the retrieval of data relevant to AIChat. +/// It also fire pixels when necessary data is missing. +struct AIChatSettings: AIChatSettingsProvider { + enum SettingsValue: String { + case aiChatURL + + var defaultValue: String { + switch self { + case .aiChatURL: return "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=4" + } + } + } + + private let privacyConfigurationManager: PrivacyConfigurationManaging + private var remoteSettings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings { + privacyConfigurationManager.privacyConfig.settings(for: .aiChat) + } + private let internalUserDecider: InternalUserDecider + private let userDefaults: UserDefaults + + init(privacyConfigurationManager: PrivacyConfigurationManaging, internalUserDecider: InternalUserDecider, userDefaults: UserDefaults = .standard) { + self.internalUserDecider = internalUserDecider + self.privacyConfigurationManager = privacyConfigurationManager + self.userDefaults = userDefaults + } + + // MARK: - Public + + var aiChatURL: URL { + guard let url = URL(string: getSettingsData(.aiChatURL)) else { + return URL(string: SettingsValue.aiChatURL.defaultValue)! + } + return url + } + + var isAIChatBrowsingMenuUserSettingsEnabled: Bool { + userDefaults.showAIChatBrowsingMenu + } + + var isAIChatFeatureEnabled: Bool { + privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .aiChat) || internalUserDecider.isInternalUser + } + + var isAIChatBrowsingToolbarShortcutFeatureEnabled: Bool { + let isBrowsingToolbarShortcutFeatureFlagEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(AIChatSubfeature.browsingToolbarShortcut) + let isInternalUser = internalUserDecider.isInternalUser + let isFeatureEnabled = isBrowsingToolbarShortcutFeatureFlagEnabled || isInternalUser + return isFeatureEnabled && isAIChatBrowsingMenuUserSettingsEnabled + } + + func enableAIChatBrowsingMenuUserSettings(enable: Bool) { + userDefaults.showAIChatBrowsingMenu = enable + } + + // MARK: - Private + + private func getSettingsData(_ value: SettingsValue) -> String { + if let value = remoteSettings[value.rawValue] as? String { + return value + } else { + Pixel.fire(pixel: .aiChatNoRemoteSettingsFound(settings: value.rawValue)) + return value.defaultValue + } + } +} + +private extension UserDefaults { + enum Keys { + static let showAIChatBrowsingMenu = "aichat.settings.showAIChatBrowsingMenu" + } + + static let showAIChatBrowsingMenuDefaultValue = true + + @objc dynamic var showAIChatBrowsingMenu: Bool { + get { + value(forKey: Keys.showAIChatBrowsingMenu) as? Bool ?? Self.showAIChatBrowsingMenuDefaultValue + } + + set { + guard newValue != showAIChatBrowsingMenu else { return } + set(newValue, forKey: Keys.showAIChatBrowsingMenu) + } + } +} diff --git a/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/AIChat-24.pdf b/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/AIChat-24.pdf new file mode 100644 index 0000000000..b806da0f59 Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/AIChat-24.pdf differ diff --git a/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/Contents.json new file mode 100644 index 0000000000..014a3657bf --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "AIChat-24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/BrowsingMenu/BrowsingMenu.xcassets/MenuAIChat.imageset/AIChat.pdf b/DuckDuckGo/BrowsingMenu/BrowsingMenu.xcassets/MenuAIChat.imageset/AIChat.pdf new file mode 100644 index 0000000000..dcd9462e46 Binary files /dev/null and b/DuckDuckGo/BrowsingMenu/BrowsingMenu.xcassets/MenuAIChat.imageset/AIChat.pdf differ diff --git a/DuckDuckGo/BrowsingMenu/BrowsingMenu.xcassets/MenuAIChat.imageset/Contents.json b/DuckDuckGo/BrowsingMenu/BrowsingMenu.xcassets/MenuAIChat.imageset/Contents.json new file mode 100644 index 0000000000..0fcb063039 --- /dev/null +++ b/DuckDuckGo/BrowsingMenu/BrowsingMenu.xcassets/MenuAIChat.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AIChat.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/BrowsingMenu/BrowsingMenuViewController.swift b/DuckDuckGo/BrowsingMenu/BrowsingMenuViewController.swift index 6b7f6b9abe..cc3d04d61b 100644 --- a/DuckDuckGo/BrowsingMenu/BrowsingMenuViewController.swift +++ b/DuckDuckGo/BrowsingMenu/BrowsingMenuViewController.swift @@ -249,8 +249,9 @@ extension BrowsingMenuViewController: UITableViewDelegate { switch menuEntries[indexPath.row] { case .regular(_, _, _, _, let action): - dismiss(animated: true) - action() + dismiss(animated: true) { + action() + } case .separator: break } diff --git a/DuckDuckGo/MainViewController+Segues.swift b/DuckDuckGo/MainViewController+Segues.swift index 5eafe2cdea..beee686007 100644 --- a/DuckDuckGo/MainViewController+Segues.swift +++ b/DuckDuckGo/MainViewController+Segues.swift @@ -296,6 +296,9 @@ extension MainViewController { fireproofing: fireproofing, websiteDataManager: websiteDataManager) + let aiChatSettings = AIChatSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + internalUserDecider: AppDependencyProvider.shared.internalUserDecider) + let settingsViewModel = SettingsViewModel(legacyViewProvider: legacyViewProvider, subscriptionManager: AppDependencyProvider.shared.subscriptionManager, subscriptionFeatureAvailability: subscriptionFeatureAvailability, @@ -304,7 +307,8 @@ extension MainViewController { historyManager: historyManager, syncPausedStateManager: syncPausedStateManager, privacyProDataReporter: privacyProDataReporter, - textZoomCoordinator: textZoomCoordinator) + textZoomCoordinator: textZoomCoordinator, + aiChatSettings: aiChatSettings) Pixel.fire(pixel: .settingsPresented) if let navigationController = self.presentedViewController as? UINavigationController, diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 506727e17f..c5c3e4a0e9 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -38,6 +38,7 @@ import Onboarding import os.log import PageRefreshMonitor import BrokenSitePrompt +import AIChat class MainViewController: UIViewController { @@ -186,6 +187,16 @@ class MainViewController: UIViewController { var appDidFinishLaunchingStartTime: CFAbsoluteTime? + private lazy var aiChatNavigationController: UINavigationController = { + let settings = AIChatSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + internalUserDecider: AppDependencyProvider.shared.internalUserDecider) + let aiChatViewController = AIChatViewController(settings: settings, + webViewConfiguration: WKWebViewConfiguration.persistent(), + pixelHandler: AIChatPixelHandler()) + aiChatViewController.delegate = self + return UINavigationController(rootViewController: aiChatViewController) + }() + init( bookmarksDatabase: CoreDataDatabase, bookmarksDatabaseCleaner: BookmarkDatabaseCleaner, @@ -351,6 +362,7 @@ class MainViewController: UIViewController { let launchTime = CFAbsoluteTimeGetCurrent() - appDidFinishLaunchingStartTime Pixel.fire(pixel: .appDidShowUITime(time: Pixel.Event.BucketAggregation(number: launchTime)), withAdditionalParameters: [PixelParameters.time: String(launchTime)]) + self.appDidFinishLaunchingStartTime = nil /// We only want this pixel to be fired once } } @@ -1689,7 +1701,6 @@ class MainViewController: UIViewController { Pixel.fire(pixel: pixel, withAdditionalParameters: pixelParameters, includedParameters: [.atb]) } - } extension MainViewController: FindInPageDelegate { @@ -2348,6 +2359,11 @@ extension MainViewController: TabDelegate { segueToReportBrokenSite(entryPoint: .toggleReport(completionHandler: completionHandler)) } + func tabDidRequestAIChat(tab: TabViewController) { + aiChatNavigationController.modalPresentationStyle = .fullScreen + tab.present(aiChatNavigationController, animated: true, completion: nil) + } + func tabDidRequestBookmarks(tab: TabViewController) { Pixel.fire(pixel: .bookmarksButtonPressed, withAdditionalParameters: [PixelParameters.originatedFromMenu: "1"]) @@ -2932,3 +2948,10 @@ extension MainViewController: AutofillLoginSettingsListViewControllerDelegate { controller.dismiss(animated: true) } } + +// MARK: - AIChatViewControllerDelegate +extension MainViewController: AIChatViewControllerDelegate { + func aiChatViewController(_ viewController: AIChatViewController, didRequestToLoad url: URL) { + loadUrlInNewTab(url, inheritedAttribution: nil) + } +} diff --git a/DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/Contents.json b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/Contents.json new file mode 100644 index 0000000000..356f0fd6ea --- /dev/null +++ b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "SettingsAIChat.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/SettingsAIChat.pdf b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/SettingsAIChat.pdf new file mode 100644 index 0000000000..adad6866a4 Binary files /dev/null and b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/SettingsAIChat.pdf differ diff --git a/DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/Contents.json b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/Contents.json new file mode 100644 index 0000000000..bb0ab0cbc3 --- /dev/null +++ b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "SettingsAIChatHero.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/SettingsAIChatHero.pdf b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/SettingsAIChatHero.pdf new file mode 100644 index 0000000000..a47a181019 Binary files /dev/null and b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/SettingsAIChatHero.pdf differ diff --git a/DuckDuckGo/SettingsAIChatView.swift b/DuckDuckGo/SettingsAIChatView.swift new file mode 100644 index 0000000000..df24751dbb --- /dev/null +++ b/DuckDuckGo/SettingsAIChatView.swift @@ -0,0 +1,54 @@ +// +// SettingsAIChatView.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DesignResourcesKit + +struct SettingsAIChatView: View { + @EnvironmentObject var viewModel: SettingsViewModel + + var body: some View { + List { + + VStack(alignment: .center) { + Image("SettingsAIChatHero") + .padding(.top, -20) + + Text(UserText.aiChatFeatureName) + .daxTitle3() + + Text(.init(UserText.aiChatSettingsCaptionWithLinkMarkdown)) + .tint(Color.init(designSystemColor: .accent)) + .daxBodyRegular() + .multilineTextAlignment(.center) + .foregroundColor(Color(designSystemColor: .textSecondary)) + .padding(.top, 12) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + + Section { + SettingsCellView(label: UserText.aiChatSettingsEnableBrowsingMenuToggle, + accessory: .toggle(isOn: viewModel.aiChatEnabledBinding)) + } + }.applySettingsListModifiers(title: UserText.aiChatFeatureName, + displayMode: .inline, + viewModel: viewModel) + } +} diff --git a/DuckDuckGo/SettingsMainSettingsView.swift b/DuckDuckGo/SettingsMainSettingsView.swift index 02b487031b..9fe96cd819 100644 --- a/DuckDuckGo/SettingsMainSettingsView.swift +++ b/DuckDuckGo/SettingsMainSettingsView.swift @@ -67,7 +67,7 @@ struct SettingsMainSettingsView: View { SettingsCellView(label: UserText.dataClearing, image: Image("SettingsDataClearing")) } - + // Duck Player // We need to hide the settings until the user is enrolled in the experiment if viewModel.state.duckPlayerEnabled { @@ -76,6 +76,14 @@ struct SettingsMainSettingsView: View { image: Image("SettingsDuckPlayer")) } } + + // AI Chat + if viewModel.state.aiChatEnabled { + NavigationLink(destination: SettingsAIChatView().environmentObject(viewModel)) { + SettingsCellView(label: UserText.aiChatFeatureName, + image: Image("SettingsAIChat")) + } + } } } diff --git a/DuckDuckGo/SettingsRootView.swift b/DuckDuckGo/SettingsRootView.swift index 0378363566..01ad7b6a60 100644 --- a/DuckDuckGo/SettingsRootView.swift +++ b/DuckDuckGo/SettingsRootView.swift @@ -129,6 +129,8 @@ struct SettingsRootView: View { SettingsDuckPlayerView().environmentObject(viewModel) case .netP: NetworkProtectionRootView() + case .aiChat: + SettingsAIChatView().environmentObject(viewModel) } } diff --git a/DuckDuckGo/SettingsState.swift b/DuckDuckGo/SettingsState.swift index 6d1fb7c43d..af6d1a27bb 100644 --- a/DuckDuckGo/SettingsState.swift +++ b/DuckDuckGo/SettingsState.swift @@ -102,7 +102,10 @@ struct SettingsState { var duckPlayerMode: DuckPlayerMode? var duckPlayerOpenInNewTab: Bool var duckPlayerOpenInNewTabEnabled: Bool - + + // AI Chat + var aiChatEnabled: Bool + static var defaults: SettingsState { return SettingsState( appTheme: .systemDefault, @@ -142,7 +145,8 @@ struct SettingsState { duckPlayerEnabled: false, duckPlayerMode: .alwaysAsk, duckPlayerOpenInNewTab: true, - duckPlayerOpenInNewTabEnabled: false + duckPlayerOpenInNewTabEnabled: false, + aiChatEnabled: false ) } } diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index a97700ad72..935eb18ca5 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -28,6 +28,7 @@ import DuckPlayer import Subscription import NetworkProtection +import AIChat final class SettingsViewModel: ObservableObject { @@ -44,6 +45,7 @@ final class SettingsViewModel: ObservableObject { private let historyManager: HistoryManaging let privacyProDataReporter: PrivacyProDataReporting? let textZoomCoordinator: TextZoomCoordinating + let aiChatSettings: AIChatSettingsProvider // Subscription Dependencies let subscriptionManager: SubscriptionManager @@ -259,6 +261,15 @@ final class SettingsViewModel: ObservableObject { ) } + var aiChatEnabledBinding: Binding { + Binding( + get: { self.aiChatSettings.isAIChatBrowsingMenuUserSettingsEnabled }, + set: { newValue in + self.aiChatSettings.enableAIChatBrowsingMenuUserSettings(enable: newValue) + } + ) + } + var textZoomLevelBinding: Binding { Binding( get: { self.state.textZoom.level }, @@ -386,7 +397,8 @@ final class SettingsViewModel: ObservableObject { historyManager: HistoryManaging, syncPausedStateManager: any SyncPausedStateManaging, privacyProDataReporter: PrivacyProDataReporting, - textZoomCoordinator: TextZoomCoordinating) { + textZoomCoordinator: TextZoomCoordinating, + aiChatSettings: AIChatSettingsProvider) { self.state = SettingsState.defaults self.legacyViewProvider = legacyViewProvider @@ -398,6 +410,7 @@ final class SettingsViewModel: ObservableObject { self.syncPausedStateManager = syncPausedStateManager self.privacyProDataReporter = privacyProDataReporter self.textZoomCoordinator = textZoomCoordinator + self.aiChatSettings = aiChatSettings setupNotificationObservers() updateRecentlyVisitedSitesVisibility() @@ -447,8 +460,9 @@ extension SettingsViewModel { duckPlayerEnabled: featureFlagger.isFeatureOn(.duckPlayer) || shouldDisplayDuckPlayerContingencyMessage, duckPlayerMode: appSettings.duckPlayerMode, duckPlayerOpenInNewTab: appSettings.duckPlayerOpenInNewTab, - duckPlayerOpenInNewTabEnabled: featureFlagger.isFeatureOn(.duckPlayerOpenInNewTab) - + duckPlayerOpenInNewTabEnabled: featureFlagger.isFeatureOn(.duckPlayerOpenInNewTab), + aiChatEnabled: aiChatSettings.isAIChatFeatureEnabled + ) updateRecentlyVisitedSitesVisibility() @@ -665,6 +679,7 @@ extension SettingsViewModel { case subscriptionFlow(origin: String? = nil) case restoreFlow case duckPlayer + case aiChat // Add other cases as needed var id: String { @@ -675,6 +690,7 @@ extension SettingsViewModel { case .subscriptionFlow: return "subscriptionFlow" case .restoreFlow: return "restoreFlow" case .duckPlayer: return "duckPlayer" + case .aiChat: return "aiChat" // Ensure all cases are covered } } @@ -683,7 +699,7 @@ extension SettingsViewModel { // Default to .sheet, specify .push where needed var type: DeepLinkType { switch self { - case .netP, .dbp, .itr, .subscriptionFlow, .restoreFlow, .duckPlayer: + case .netP, .dbp, .itr, .subscriptionFlow, .restoreFlow, .duckPlayer, .aiChat: return .navigationLink } } diff --git a/DuckDuckGo/TabDelegate.swift b/DuckDuckGo/TabDelegate.swift index 90424e25fb..6865b4bf65 100644 --- a/DuckDuckGo/TabDelegate.swift +++ b/DuckDuckGo/TabDelegate.swift @@ -59,6 +59,8 @@ protocol TabDelegate: AnyObject { func tabDidRequestDownloads(tab: TabViewController) + func tabDidRequestAIChat(tab: TabViewController) + func tabDidRequestAutofillLogins(tab: TabViewController) func tabDidRequestSettings(tab: TabViewController) diff --git a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift index d76c6c18b2..8cba18608b 100644 --- a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift @@ -28,25 +28,32 @@ import PrivacyDashboard extension TabViewController { - func buildBrowsingMenuHeaderContent() -> [BrowsingMenuEntry] { + private var shouldShowAIChatInMenuHeader: Bool { + let settings = AIChatSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + internalUserDecider: AppDependencyProvider.shared.internalUserDecider) + return settings.isAIChatBrowsingToolbarShortcutFeatureEnabled + } + + private var shouldShowPrintButtonInBrowsingMenuList: Bool { shouldShowAIChatInMenuHeader } + func buildBrowsingMenuHeaderContent() -> [BrowsingMenuEntry] { var entries = [BrowsingMenuEntry]() - entries.append(BrowsingMenuEntry.regular(name: UserText.actionNewTab, - accessibilityLabel: UserText.keyCommandNewTab, - image: UIImage(named: "Add-24")!, - action: { [weak self] in + let newTabEntry = BrowsingMenuEntry.regular(name: UserText.actionNewTab, + accessibilityLabel: UserText.keyCommandNewTab, + image: UIImage(named: "Add-24")!, + action: { [weak self] in self?.onNewTabAction() - })) + }) - entries.append(BrowsingMenuEntry.regular(name: UserText.actionShare, image: UIImage(named: "Share-24")!, action: { [weak self] in + let shareEntry = BrowsingMenuEntry.regular(name: UserText.actionShare, image: UIImage(named: "Share-24")!, action: { [weak self] in guard let self = self else { return } guard let menu = self.chromeDelegate?.omniBar.menuButton else { return } Pixel.fire(pixel: .browsingMenuShare) self.onShareAction(forLink: self.link!, fromView: menu) - })) + }) - entries.append(BrowsingMenuEntry.regular(name: UserText.actionCopy, image: UIImage(named: "Copy-24")!, action: { [weak self] in + let copyEntry = BrowsingMenuEntry.regular(name: UserText.actionCopy, image: UIImage(named: "Copy-24")!, action: { [weak self] in guard let strongSelf = self else { return } if !strongSelf.isError, let url = strongSelf.webView.url { strongSelf.onCopyAction(forUrl: url) @@ -58,16 +65,34 @@ extension TabViewController { let addressBarBottom = strongSelf.appSettings.currentAddressBarPosition.isBottom ActionMessageView.present(message: UserText.actionCopyMessage, presentationLocation: .withBottomBar(andAddressBarBottom: addressBarBottom)) - })) + }) - entries.append(BrowsingMenuEntry.regular(name: UserText.actionPrint, image: UIImage(named: "Print-24")!, action: { [weak self] in + let printEntry = BrowsingMenuEntry.regular(name: UserText.actionPrint, image: UIImage(named: "Print-24")!, action: { [weak self] in Pixel.fire(pixel: .browsingMenuPrint) self?.print() - })) + }) + + let chatEntry = BrowsingMenuEntry.regular(name: UserText.actionOpenAIChat, image: UIImage(named: "AIChat-24")!, action: { [weak self] in + Pixel.fire(pixel: .browsingMenuAIChat) + self?.openAIChat() + }) + + if shouldShowAIChatInMenuHeader { + entries.append(newTabEntry) + entries.append(chatEntry) + entries.append(shareEntry) + entries.append(copyEntry) + } else { + entries.append(newTabEntry) + entries.append(shareEntry) + entries.append(copyEntry) + entries.append(printEntry) + } return entries } + var favoriteEntryIndex: Int { 1 } func buildShortcutsMenu() -> [BrowsingMenuEntry] { @@ -80,6 +105,16 @@ extension TabViewController { let linkEntries = buildLinkEntries(with: bookmarksInterface) entries.append(contentsOf: linkEntries) + if shouldShowPrintButtonInBrowsingMenuList { + entries.append(.regular(name: UserText.actionPrintSite, + accessibilityLabel: UserText.actionPrintSite, + image: UIImage(named: "Print-16")!, + action: { [weak self] in + Pixel.fire(pixel: .browsingMenuListPrint) + self?.print() + })) + } + if let domain = self.privacyInfo?.domain { entries.append(self.buildToggleProtectionEntry(forDomain: domain)) } @@ -440,6 +475,10 @@ extension TabViewController { delegate?.tabDidRequestBookmarks(tab: self) } + private func openAIChat() { + delegate?.tabDidRequestAIChat(tab: self) + } + private func buildToggleProtectionEntry(forDomain domain: String) -> BrowsingMenuEntry { let config = ContentBlocking.shared.privacyConfigurationManager.privacyConfig let isProtected = !config.isUserUnprotected(domain: domain) diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index f4d0d7460b..ff3430d987 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -49,7 +49,10 @@ public struct UserText { public static let actionCopy = NSLocalizedString("action.title.copy", value: "Copy", comment: "Copy action") public static let actionCopyMessage = NSLocalizedString("action.title.copy.message", value: "URL copied", comment: "Floating message indicating URL has been copied") public static let actionShare = NSLocalizedString("action.title.share", value: "Share", comment: "Share action") - public static let actionPrint = NSLocalizedString("action.title.print", value: "Print", comment: "Print action") + public static let actionPrint = NSLocalizedString("action.title.print", value: "Print", comment: "Print action in the menu header") + public static let actionPrintSite = NSLocalizedString("action.title.print.site", value: "Print", comment: "Print action in the menu list") + public static let actionOpenAIChat = NSLocalizedString("action.title.aichat", value: "Chat", comment: "Open AI Chat action in the menu list") + public static let actionOpenBookmarks = NSLocalizedString("action.title.bookmarks", value: "Bookmarks", comment: "Button: Open bookmarks list") public static let actionEnableProtection = NSLocalizedString("action.title.enable.protection", value: "Enable Privacy Protection", comment: "Enable protection action") public static let actionDisableProtection = NSLocalizedString("action.title.disable.protection", value: "Disable Privacy Protection", comment: "Disable protection action") @@ -1293,8 +1296,8 @@ But if you *do* want a peek under the hood, you can find more information about public static let settingsOpenVideosInDuckPlayerLabel = NSLocalizedString("duckplayer.settings.open-videos-in", value: "Open YouTube Videos in Duck Player", comment: "Settings screen cell text for DuckPlayer settings") public static let duckPlayerFeatureName = NSLocalizedString("duckplayer.settings.title", value: "Duck Player", comment: "Settings screen cell text for DuckPlayer settings") - public static let settingsOpenDuckPlayerNewTabLabel = NSLocalizedString("duckplayer.settings.open-new-tab-label", value: "Open Duck Player in a new tab", comment: "Settings screen cell text for DuckPlayer settings to open in new tab") - + public static let settingsOpenDuckPlayerNewTabLabel = NSLocalizedString("duckplayer.settings.open-new-tab-label", value: "Open Duck Player in a New Tab", comment: "Settings screen cell text for DuckPlayer settings to open in new tab") + public static let settingsOpenVideosInDuckPlayerTitle = NSLocalizedString("duckplayer.settings.title", value: "Duck Player", comment: "Settings screen cell text for DuckPlayer settings") public static let settingsDuckPlayerFooter = NSLocalizedString("duckplayer.settings.footer", value: "DuckDuckGo provides all the privacy essentials you need to protect yourself as you browse the web.", comment: "Footer label in the settings screen for Duck Player") @@ -1310,6 +1313,18 @@ But if you *do* want a peek under the hood, you can find more information about static let duckPlayerContingencyMessageBody = NSLocalizedString("duck-player.video-contingency-message", value: "Duck Player's functionality has been affected by recent changes to YouTube. We’re working to fix these issues and appreciate your understanding.", comment: "Message explaining to the user that Duck Player is not available") static let duckPlayerContingencyMessageCTA = NSLocalizedString("duck-player.video-contingency-cta", value: "Learn More", comment: "Button for the message explaining to the user that Duck Player is not available so the user can learn more") + // MARK: - AI Chat + public static let aiChatTitle = NSLocalizedString("aichat.title", value: "DuckDuckGo AI Chat", comment: "Title for DuckDuckGo AI Chat. Should not be translated") + public static let aiChatFeatureName = NSLocalizedString("aichat.settings.title", value: "AI Chat", comment: "Settings screen cell text for AI Chat settings") + + public static let aiChatSettingsEnableFooter = NSLocalizedString("aichat.settings.enable.footer", value: "Turning this off will hide the AI Chat feature in the DuckDuckGo app.", comment: "Footer text for AI Chat settings") + static let aiChatSettingsCaptionWithLinkMarkdown = NSLocalizedString("ai-chat.preferences.text.markdown", value: """ +AI Chat is an optional feature available at [duck.ai](ddgquicklink://duck.ai) that lets you have private conversations with popular 3rd-party AI chat models. Your chats are not used to train chat models. +[Learn More](ddgquicklink://duckduckgo.com/duckduckgo-help-pages/aichat/) +""", comment: "Ai Chat preferences explanation with a markdown link. Do not translate what's inside [] and ()") + public static let aiChatSettingsEnableBrowsingMenuToggle = NSLocalizedString("aichat.settings.enable.browsing-menu-toggle", value: "Show AI Chat in Browser Menu", comment: "Toggle text to enable/disable AI Chat in the browsing menu") + + // MARK: - New Tab Page // MARK: Shortcuts diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index f12ff46fea..f65a3bac1a 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* Add action - button shown in alert */ "action.title.add" = "Add"; +/* Open AI Chat action in the menu list */ +"action.title.aichat" = "Chat"; + /* Autofill Logins menu item opening the login list */ "action.title.autofill.logins" = "Passwords"; @@ -79,9 +82,12 @@ /* Paste and Go action */ "action.title.pasteAndGo" = "Paste & Go"; -/* Print action */ +/* Print action in the menu header */ "action.title.print" = "Print"; +/* Print action in the menu list */ +"action.title.print.site" = "Print"; + /* Refresh action - button shown in alert */ "action.title.refresh" = "Refresh"; @@ -136,6 +142,22 @@ /* No comment provided by engineer. */ "addWidget.title" = "One tap to your favorite sites."; +/* Ai Chat preferences explanation with a markdown link. Do not translate what's inside [] and () */ +"ai-chat.preferences.text.markdown" = "AI Chat is an optional feature available at [duck.ai](ddgquicklink://duck.ai) that lets you have private conversations with popular 3rd-party AI chat models. Your chats are not used to train chat models. +[Learn More](ddgquicklink://duckduckgo.com/duckduckgo-help-pages/aichat/)"; + +/* Toggle text to enable/disable AI Chat in the browsing menu */ +"aichat.settings.enable.browsing-menu-toggle" = "Show AI Chat in Browser Menu"; + +/* Footer text for AI Chat settings */ +"aichat.settings.enable.footer" = "Turning this off will hide the AI Chat feature in the DuckDuckGo app."; + +/* Settings screen cell text for AI Chat settings */ +"aichat.settings.title" = "AI Chat"; + +/* Title for DuckDuckGo AI Chat. Should not be translated */ +"aichat.title" = "DuckDuckGo AI Chat"; + /* No comment provided by engineer. */ "alert.message.bookmarkAll" = "Existing bookmarks will not be duplicated."; @@ -1083,7 +1105,7 @@ "duckplayer.settings.learn-more" = "Learn More"; /* Settings screen cell text for DuckPlayer settings to open in new tab */ -"duckplayer.settings.open-new-tab-label" = "Open Duck Player in a new tab"; +"duckplayer.settings.open-new-tab-label" = "Open Duck Player in a New Tab"; /* Settings screen cell text for DuckPlayer settings */ "duckplayer.settings.open-videos-in" = "Open YouTube Videos in Duck Player"; diff --git a/DuckDuckGoTests/AIChat/AIChatSettingsTests.swift b/DuckDuckGoTests/AIChat/AIChatSettingsTests.swift new file mode 100644 index 0000000000..93667a4aa9 --- /dev/null +++ b/DuckDuckGoTests/AIChat/AIChatSettingsTests.swift @@ -0,0 +1,131 @@ +// +// AIChatSettingsTests.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Core +@testable import DuckDuckGo +import BrowserServicesKit +import Combine + +class AIChatSettingsTests: XCTestCase { + + private var mockPrivacyConfigurationManager: PrivacyConfigurationManagerMock! + private var mockInternalUserDecider: MockInternalUserDecider! + private var mockUserDefaults: UserDefaults! + + override func setUp() { + super.setUp() + mockPrivacyConfigurationManager = PrivacyConfigurationManagerMock() + mockInternalUserDecider = MockInternalUserDecider() + mockUserDefaults = UserDefaults(suiteName: "TestDefaults") + } + + override func tearDown() { + mockUserDefaults.removePersistentDomain(forName: "TestDefaults") + mockPrivacyConfigurationManager = nil + mockInternalUserDecider = nil + mockUserDefaults = nil + super.tearDown() + } + + func testAIChatURLReturnsDefaultWhenRemoteSettingsMissing() { + let settings = AIChatSettings(privacyConfigurationManager: mockPrivacyConfigurationManager, + internalUserDecider: mockInternalUserDecider, + userDefaults: mockUserDefaults) + + (mockPrivacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.settings = [:] + + let expectedURL = URL(string: AIChatSettings.SettingsValue.aiChatURL.defaultValue)! + XCTAssertEqual(settings.aiChatURL, expectedURL) + } + + func testAIChatURLReturnsRemoteSettingWhenAvailable() { + let settings = AIChatSettings(privacyConfigurationManager: mockPrivacyConfigurationManager, + internalUserDecider: mockInternalUserDecider, + userDefaults: mockUserDefaults) + + let remoteURL = "https://example.com/ai-chat" + (mockPrivacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.settings = [ + .aiChat: [AIChatSettings.SettingsValue.aiChatURL.rawValue: remoteURL] + ] + + XCTAssertEqual(settings.aiChatURL, URL(string: remoteURL)) + } + + func testIsAIChatFeatureEnabledWhenFeatureIsEnabled() { + let settings = AIChatSettings(privacyConfigurationManager: mockPrivacyConfigurationManager, + internalUserDecider: mockInternalUserDecider, + userDefaults: mockUserDefaults) + + (mockPrivacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.enabledFeaturesForVersions = [ + .aiChat: [AppVersionProvider().appVersion() ?? ""] + ] + + XCTAssertTrue(settings.isAIChatFeatureEnabled) + } + + func testIsAIChatFeatureEnabledForInternalUser() { + let settings = AIChatSettings(privacyConfigurationManager: mockPrivacyConfigurationManager, + internalUserDecider: mockInternalUserDecider, + userDefaults: mockUserDefaults) + + mockInternalUserDecider.mockIsInternalUser = true + XCTAssertTrue(settings.isAIChatFeatureEnabled) + } + + func testEnableAIChatBrowsingMenuUserSettings() { + let settings = AIChatSettings(privacyConfigurationManager: mockPrivacyConfigurationManager, + internalUserDecider: mockInternalUserDecider, + userDefaults: mockUserDefaults) + + (mockPrivacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.enabledFeaturesForVersions = [ + .aiChat: [AppVersionProvider().appVersion() ?? ""] + ] + + (mockPrivacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.enabledSubfeaturesForVersions = [ + AIChatSubfeature.browsingToolbarShortcut.rawValue: [AppVersionProvider().appVersion() ?? ""] + ] + settings.enableAIChatBrowsingMenuUserSettings(enable: false) + XCTAssertFalse(settings.isAIChatBrowsingToolbarShortcutFeatureEnabled) + + settings.enableAIChatBrowsingMenuUserSettings(enable: true) + XCTAssertTrue(settings.isAIChatBrowsingToolbarShortcutFeatureEnabled) + } +} + + +final private class MockInternalUserDecider: InternalUserDecider { + var mockIsInternalUser: Bool = false + var mockIsInternalUserPublisher: AnyPublisher { + Just(mockIsInternalUser).eraseToAnyPublisher() + } + + var isInternalUser: Bool { + return mockIsInternalUser + } + + var isInternalUserPublisher: AnyPublisher { + return mockIsInternalUserPublisher + } + + @discardableResult + func markUserAsInternalIfNeeded(forUrl url: URL?, response: HTTPURLResponse?) -> Bool { + return mockIsInternalUser + } +} diff --git a/DuckDuckGoTests/MockTabDelegate.swift b/DuckDuckGoTests/MockTabDelegate.swift index 7878b46cbd..47522ae542 100644 --- a/DuckDuckGoTests/MockTabDelegate.swift +++ b/DuckDuckGoTests/MockTabDelegate.swift @@ -68,6 +68,8 @@ final class MockTabDelegate: TabDelegate { func tabDidRequestAutofillLogins(tab: DuckDuckGo.TabViewController) {} + func tabDidRequestAIChat(tab: TabViewController) {} + func tabDidRequestSettings(tab: DuckDuckGo.TabViewController) {} func tab(_ tab: DuckDuckGo.TabViewController, didRequestSettingsToLogins account: BrowserServicesKit.SecureVaultModels.WebsiteAccount) {} diff --git a/DuckDuckGoTests/StatisticsLoaderTests.swift b/DuckDuckGoTests/StatisticsLoaderTests.swift index f066097e99..8fa235203c 100644 --- a/DuckDuckGoTests/StatisticsLoaderTests.swift +++ b/DuckDuckGoTests/StatisticsLoaderTests.swift @@ -27,20 +27,26 @@ class StatisticsLoaderTests: XCTestCase { var mockStatisticsStore: StatisticsStore! var mockUsageSegmentation: MockUsageSegmentation! + var mockPixelFiring: PixelFiringMock.Type! var testee: StatisticsLoader! override func setUpWithError() throws { try super.setUpWithError() + PixelFiringMock.tearDown() + + mockPixelFiring = PixelFiringMock.self mockStatisticsStore = MockStatisticsStore() mockUsageSegmentation = MockUsageSegmentation() testee = StatisticsLoader(statisticsStore: mockStatisticsStore, usageSegmentation: mockUsageSegmentation, - inconsistencyMonitoring: MockStatisticsStoreInconsistencyMonitoring()) + inconsistencyMonitoring: MockStatisticsStoreInconsistencyMonitoring(), + pixelFiring: mockPixelFiring) } override func tearDown() { HTTPStubs.removeAllStubs() + PixelFiringMock.tearDown() super.tearDown() } @@ -270,6 +276,19 @@ class StatisticsLoaderTests: XCTestCase { waitForExpectations(timeout: 5, handler: nil) } + func testWhenInstallStatisticsRequestedThenInstallPixelIsFired() { + loadSuccessfulExiStub() + + let testExpectation = expectation(description: "refresh complete") + testee.refreshAppRetentionAtb { + Thread.sleep(forTimeInterval: .seconds(0.1)) + testExpectation.fulfill() + } + + wait(for: [testExpectation], timeout: 5.0) + XCTAssertEqual(mockPixelFiring.lastPixelName, Pixel.Event.appInstall.name) + } + func loadSuccessfulAtbStub() { stub(condition: isHost(URL.atb.host!)) { _ in let path = OHPathForFile("MockFiles/atb.json", type(of: self))! diff --git a/LocalPackages/AIChat/Package.swift b/LocalPackages/AIChat/Package.swift new file mode 100644 index 0000000000..70d6f9499d --- /dev/null +++ b/LocalPackages/AIChat/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "AIChat", + platforms: [ + .iOS(.v15) + ], + products: [ + .library( + name: "AIChat", + targets: ["AIChat"]), + ], + targets: [ + .target( + name: "AIChat"), + ] +) diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift new file mode 100644 index 0000000000..36c326dd8a --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift @@ -0,0 +1,81 @@ +// +// AIChatViewModel.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 WebKit +import Combine +import os.log + +protocol AIChatViewModeling { + /// The URL to be loaded in the AI Chat View Controller's web view. + var aiChatURL: URL { get } + + /// The configuration settings for the web view used in the AI Chat. + /// This configuration can include preferences such as data storage + var webViewConfiguration: WKWebViewConfiguration { get } + + /// A publisher that emits a signal after a 10-minute interval. + /// This is used to notify the controller that it should perform a reload or cleanup operation, + var cleanupPublisher: PassthroughSubject { get } + + /// Cancels the currently active cleanup timer. + func cancelTimer() + + /// Initiates the cleanup timer, which is set to trigger after a specified duration. + /// The purpose of this timer is to clear previous chat conversations + func startCleanupTimer() +} + + +final class AIChatViewModel: AIChatViewModeling { + private let settings: AIChatSettingsProvider + private var cleanupTimerCancellable: AnyCancellable? + + let webViewConfiguration: WKWebViewConfiguration + let cleanupPublisher = PassthroughSubject() + + let cleanupTime: TimeInterval + + init(webViewConfiguration: WKWebViewConfiguration, settings: AIChatSettingsProvider, cleanupTime: TimeInterval = 600) { + self.cleanupTime = cleanupTime + self.webViewConfiguration = webViewConfiguration + self.settings = settings + } + + func cancelTimer() { + Logger.aiChat.debug("Cancelling cleanup timer") + cleanupTimerCancellable?.cancel() + } + + func startCleanupTimer() { + cancelTimer() + + Logger.aiChat.debug("Starting cleanup timer") + + cleanupTimerCancellable = Just(()) + .delay(for: .seconds(cleanupTime), scheduler: RunLoop.main) + .sink { [weak self] in + Logger.aiChat.debug("Cleanup timer done") + self?.cleanupPublisher.send() + } + } + + var aiChatURL: URL { + settings.aiChatURL + } +} diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift new file mode 100644 index 0000000000..ec0d1d5d2d --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift @@ -0,0 +1,133 @@ +// +// AIChatWebViewController.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import UIKit +import WebKit + +protocol AIChatWebViewControllerDelegate: AnyObject { + @MainActor func aiChatWebViewController(_ viewController: AIChatWebViewController, didRequestToLoad url: URL) +} + +final class AIChatWebViewController: UIViewController { + weak var delegate: AIChatWebViewControllerDelegate? + private let chatModel: AIChatViewModeling + + private lazy var webView: WKWebView = { + let webView = WKWebView(frame: .zero, configuration: chatModel.webViewConfiguration) + webView.isOpaque = false /// Required to make the background color visible + webView.backgroundColor = .systemBackground + webView.navigationDelegate = self + webView.translatesAutoresizingMaskIntoConstraints = false + return webView + }() + + private lazy var loadingView: UIActivityIndicatorView = { + let activityIndicator = UIActivityIndicatorView(style: .large) + activityIndicator.color = .label + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.hidesWhenStopped = true + return activityIndicator + }() + + init(chatModel: AIChatViewModeling) { + self.chatModel = chatModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + + setupWebView() + setupLoadingView() + loadWebsite() + } + + private func setupWebView() { + view.addSubview(webView) + + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + private func setupLoadingView() { + view.addSubview(loadingView) + + NSLayoutConstraint.activate([ + loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +// MARK: - WebView functions + +extension AIChatWebViewController { + + func reload() { + loadWebsite() + } + + private func loadWebsite() { + let request = URLRequest(url: chatModel.aiChatURL) + webView.load(request) + } +} + +// MARK: - WKNavigationDelegate + +extension AIChatWebViewController: WKNavigationDelegate { + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + if let url = navigationAction.request.url { + if url == chatModel.aiChatURL || navigationAction.targetFrame?.isMainFrame == false { + return .allow + } else { + delegate?.aiChatWebViewController(self, didRequestToLoad: url) + return .cancel + } + } else { + return .allow + } + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + loadingView.startAnimating() + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + loadingView.stopAnimating() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + loadingView.stopAnimating() + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + loadingView.stopAnimating() + } +} diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatPixelHandling.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatPixelHandling.swift new file mode 100644 index 0000000000..f1981fd264 --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatPixelHandling.swift @@ -0,0 +1,27 @@ +// +// AIChatPixelHandling.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +public enum AIChatPixel { + case openBefore10min + case openAfter10min +} + +public protocol AIChatPixelHandling { + func fire(pixel: AIChatPixel) +} diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatSettingsProvider.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatSettingsProvider.swift new file mode 100644 index 0000000000..afd76aaf9b --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatSettingsProvider.swift @@ -0,0 +1,37 @@ +// +// AIChatSettingsProvider.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 + +public protocol AIChatSettingsProvider { + /// The URL used to open AI Chat in `AIChatViewController`. + var aiChatURL: URL { get } + + /// User settings state for AI Chat browsing menu icon + var isAIChatBrowsingMenuUserSettingsEnabled: Bool { get } + + /// Remote feature flag state for AI Chat + var isAIChatFeatureEnabled: Bool { get } + + /// Remote feature flag for AI Chat shortcut in browsing menu + var isAIChatBrowsingToolbarShortcutFeatureEnabled: Bool { get } + + /// Update user settings state for AI Chat browsing menu + func enableAIChatBrowsingMenuUserSettings(enable: Bool) +} diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift new file mode 100644 index 0000000000..436ec008c8 --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -0,0 +1,205 @@ +// +// AIChatViewController.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import UIKit +import Combine +import WebKit + +/// A protocol that defines the delegate methods for `AIChatViewController`. +public protocol AIChatViewControllerDelegate: AnyObject { + /// Tells the delegate that a request to load a URL has been made. + /// + /// - Parameters: + /// - viewController: The `AIChatViewController` instance making the request. + /// - url: The `URL` that is requested to be loaded. + func aiChatViewController(_ viewController: AIChatViewController, didRequestToLoad url: URL) +} + +public final class AIChatViewController: UIViewController { + public weak var delegate: AIChatViewControllerDelegate? + private let chatModel: AIChatViewModeling + private var webViewController: AIChatWebViewController? + private var cleanupCancellable: AnyCancellable? + private var didCleanup: Bool = false + private let timerPixelHandler: TimerPixelHandler + + /// Initializes a new instance of `AIChatViewController` with the specified remote settings and web view configuration. + /// + /// - Parameters: + /// - remoteSettings: An object conforming to `AIChatSettingsProvider` that provides remote settings. + /// - webViewConfiguration: A `WKWebViewConfiguration` object used to configure the web view. + /// - pixelHandler: A `AIChatPixelHandling` object used to send pixel events. + public convenience init(settings: AIChatSettingsProvider, webViewConfiguration: WKWebViewConfiguration, pixelHandler: AIChatPixelHandling) { + let chatModel = AIChatViewModel(webViewConfiguration: webViewConfiguration, settings: settings) + self.init(chatModel: chatModel, pixelHandler: pixelHandler) + } + + internal init(chatModel: AIChatViewModeling, pixelHandler: AIChatPixelHandling) { + self.chatModel = chatModel + self.timerPixelHandler = TimerPixelHandler(pixelHandler: pixelHandler) + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Lifecycle +extension AIChatViewController { + + public override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .black + + setupNavigationBar() + + subscribeToCleanupPublisher() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + addWebViewController() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + timerPixelHandler.sendOpenPixel() + chatModel.cancelTimer() + } + + public override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + + if viewIfLoaded?.window == nil { + chatModel.cancelTimer() + removeWebViewController() + } + } +} + +// MARK: - Views Setup +extension AIChatViewController { + + private func setupNavigationBar() { + guard let navigationController = navigationController else { return } + + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundColor = .clear + appearance.shadowImage = UIImage() + appearance.shadowColor = .clear + + navigationController.navigationBar.standardAppearance = appearance + navigationController.navigationBar.scrollEdgeAppearance = appearance + navigationController.navigationBar.compactAppearance = appearance + navigationController.navigationBar.isTranslucent = true + + let imageView = UIImageView(image: UIImage(named: "Logo")) + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + + let imageSize: CGFloat = 28 + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: imageSize), + imageView.heightAnchor.constraint(equalToConstant: imageSize) + ]) + + let titleLabel = UILabel() + titleLabel.text = UserText.aiChatTitle + titleLabel.font = UIFont.systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = .white + let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel]) + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.alignment = .center + stackView.distribution = .fill + + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: stackView) + + let closeButton = UIBarButtonItem( + image: UIImage(named: "Close-24"), + style: .plain, + target: self, + action: #selector(closeAIChat) + ) + closeButton.accessibilityIdentifier = "aichat.close.button" + closeButton.tintColor = .white + + navigationItem.rightBarButtonItem = closeButton + } + + + private func addWebViewController() { + guard webViewController == nil else { return } + + let viewController = AIChatWebViewController(chatModel: chatModel) + viewController.delegate = self + webViewController = viewController + + addChild(viewController) + view.addSubview(viewController.view) + viewController.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + viewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + viewController.view.backgroundColor = .black + viewController.view.layer.cornerRadius = 20 + viewController.view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + viewController.view.clipsToBounds = true + + viewController.didMove(toParent: self) + } + + private func removeWebViewController() { + webViewController?.removeFromParent() + webViewController?.view.removeFromSuperview() + webViewController = nil + } +} + +// MARK: - Event handling +extension AIChatViewController { + + private func subscribeToCleanupPublisher() { + cleanupCancellable = chatModel.cleanupPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.webViewController?.reload() + self?.timerPixelHandler.markCleanup() + } + } + + @objc private func closeAIChat() { + chatModel.startCleanupTimer() + dismiss(animated: true) + } +} + +extension AIChatViewController: AIChatWebViewControllerDelegate { + func aiChatWebViewController(_ viewController: AIChatWebViewController, didRequestToLoad url: URL) { + delegate?.aiChatViewController(self, didRequestToLoad: url) + closeAIChat() + } +} diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/Logger+AIChat.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/Logger+AIChat.swift new file mode 100644 index 0000000000..d928d98e2e --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/Logger+AIChat.swift @@ -0,0 +1,25 @@ +// +// Logger+AIChat.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 os.log + +public extension Logger { + static let aiChat = { Logger(subsystem: "AI Chat", category: "") }() +} diff --git a/LocalPackages/AIChat/Sources/AIChat/TimerPixelHandler.swift b/LocalPackages/AIChat/Sources/AIChat/TimerPixelHandler.swift new file mode 100644 index 0000000000..91f64f5cdd --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/TimerPixelHandler.swift @@ -0,0 +1,56 @@ +// +// TimerPixelHandler.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. +// + + +final class TimerPixelHandler { + private let pixelHandler: AIChatPixelHandling + private var hasCleanedUp = false + private var isFirstPixelSent = true + + init(pixelHandler: any AIChatPixelHandling) { + self.pixelHandler = pixelHandler + } + + /// Marks that cleanup has been performed. + func markCleanup() { + hasCleanedUp = true + } + + /// Sends an "open" pixel based on the cleanup status. + /// - If this is the first time sending a pixel, it will not send any pixel. + /// - If cleanup has been called, it sends an `openAfter10min` pixel. + /// - Otherwise, it sends an `openBefore10min` pixel. + func sendOpenPixel() { + defer { + hasCleanedUp = false + } + + // Skip sending a pixel on the first call. + guard !isFirstPixelSent else { + isFirstPixelSent = false + return + } + + if hasCleanedUp { + pixelHandler.fire(pixel: .openAfter10min) + } else { + pixelHandler.fire(pixel: .openBefore10min) + } + } +} diff --git a/LocalPackages/AIChat/Sources/AIChat/UserText.swift b/LocalPackages/AIChat/Sources/AIChat/UserText.swift new file mode 100644 index 0000000000..d33e745d2d --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/UserText.swift @@ -0,0 +1,24 @@ +// +// UserText.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 + +public struct UserText { + public static let aiChatTitle = NSLocalizedString("aichat.title", value: "DuckDuckGo AI Chat", comment: "Title for DuckDuckGo AI Chat. Should not be translated") +}