diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index c3e29261d6..d7433366d2 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -476,6 +476,23 @@ extension Pixel { case networkProtectionMalformedErrorDetected + // MARK: - VPN Tips + + case networkProtectionGeoswitchingTipShown + case networkProtectionGeoswitchingTipActioned + case networkProtectionGeoswitchingTipDismissed + case networkProtectionGeoswitchingTipIgnored + + case networkProtectionSnoozeTipShown + case networkProtectionSnoozeTipActioned + case networkProtectionSnoozeTipDismissed + case networkProtectionSnoozeTipIgnored + + case networkProtectionWidgetTipShown + case networkProtectionWidgetTipActioned + case networkProtectionWidgetTipDismissed + case networkProtectionWidgetTipIgnored + // MARK: remote messaging pixels case remoteMessageShown @@ -1321,6 +1338,23 @@ extension Pixel.Event { case .networkProtectionMalformedErrorDetected: return "m_netp_vpn_malformed_error_detected" + // MARK: VPN tips + + case .networkProtectionGeoswitchingTipShown: return "m_vpn_tip_geoswitching_shown" + case .networkProtectionGeoswitchingTipActioned: return "m_vpn_tip_geoswitching_actioned" + case .networkProtectionGeoswitchingTipDismissed: return "m_vpn_tip_geoswitching_dismissed" + case .networkProtectionGeoswitchingTipIgnored: return "m_vpn_tip_geoswitching_ignored" + + case .networkProtectionSnoozeTipShown: return "m_vpn_tip_snooze_shown" + case .networkProtectionSnoozeTipActioned: return "m_vpn_tip_snooze_actioned" + case .networkProtectionSnoozeTipDismissed: return "m_vpn_tip_snooze_dismissed" + case .networkProtectionSnoozeTipIgnored: return "m_vpn_tip_snooze_ignored" + + case .networkProtectionWidgetTipShown: return "m_vpn_tip_widget_shown" + case .networkProtectionWidgetTipActioned: return "m_vpn_tip_widget_actioned" + case .networkProtectionWidgetTipDismissed: return "m_vpn_tip_widget_dismissed" + case .networkProtectionWidgetTipIgnored: return "m_vpn_tip_widget_ignored" + // MARK: remote messaging pixels case .remoteMessageShown: return "m_remote_message_shown" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 2c7087b832..7b551f7299 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -365,12 +365,13 @@ 6FEC0B852C999352006B4F6E /* FavoriteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FEC0B842C999352006B4F6E /* FavoriteItem.swift */; }; 6FEC0B882C999961006B4F6E /* FavoritesListInteractingAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FEC0B872C999961006B4F6E /* FavoritesListInteractingAdapter.swift */; }; 6FF915822B88E07A0042AC87 /* AdAttributionFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */; }; - 6FF9AD452CE766F700C5A406 /* NewTabPageControllerPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD442CE766F700C5A406 /* NewTabPageControllerPixelTests.swift */; }; 6FF9AD3F2CE63DD800C5A406 /* TabSwitcherOpenDailyPixel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD3E2CE63DC200C5A406 /* TabSwitcherOpenDailyPixel.swift */; }; 6FF9AD412CE6610F00C5A406 /* TabSwitcherDailyPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD402CE6610600C5A406 /* TabSwitcherDailyPixelTests.swift */; }; + 6FF9AD452CE766F700C5A406 /* NewTabPageControllerPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD442CE766F700C5A406 /* NewTabPageControllerPixelTests.swift */; }; 7B1604E82CB685B400A44EC6 /* Logger+TipKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */; }; 7B1604EC2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */; }; 7B1604EE2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604ED2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift */; }; + 7B1C892C2CF714AA0008224E /* VPNTipsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1C892B2CF714AA0008224E /* VPNTipsModel.swift */; }; 7B8E0EC62CC81B4900B2B722 /* TipKitController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8E0EC52CC81B4800B2B722 /* TipKitController.swift */; }; 7BC571202BDBB877003B0CCE /* VPNActivationDateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */; }; 7BC571212BDBB977003B0CCE /* VPNActivationDateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */; }; @@ -470,7 +471,6 @@ 853A717820F645FB00FE60BC /* PixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 853A717720F645FB00FE60BC /* PixelTests.swift */; }; 853C5F6121C277C7001F7A05 /* global.swift in Sources */ = {isa = PBXBuildFile; fileRef = 853C5F6021C277C7001F7A05 /* global.swift */; }; 8540BBA22440857A00017FE4 /* FireproofingWorking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BBA12440857A00017FE4 /* FireproofingWorking.swift */; }; - 8540BD5223D8C2220057FDD2 /* UserDefaultsFireproofingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BD5123D8C2220057FDD2 /* UserDefaultsFireproofingTests.swift */; }; 8540BD5423D8D5080057FDD2 /* FireproofingAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BD5323D8D5080057FDD2 /* FireproofingAlert.swift */; }; 8540BD5623D9E9C20057FDD2 /* FireproofingSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BD5523D9E9C20057FDD2 /* FireproofingSettingsViewController.swift */; }; 85449EF523FDA02800512AAF /* KeyboardSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85449EF423FDA02800512AAF /* KeyboardSettingsViewController.swift */; }; @@ -651,7 +651,7 @@ 98424AAD2CED4FF10071C7DB /* WKWebViewConfigurationExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F198D7971E3A45D90088DA8A /* WKWebViewConfigurationExtensionTests.swift */; }; 98424AAE2CED4FF10071C7DB /* WebCacheManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850559D123CF710C0055C0D5 /* WebCacheManagerTests.swift */; }; 98424AAF2CED4FF10071C7DB /* UserAgentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 834DF990248FDDF60075EA48 /* UserAgentTests.swift */; }; - 98424AB02CED4FF10071C7DB /* PreserveLoginsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BD5123D8C2220057FDD2 /* PreserveLoginsTests.swift */; }; + 98424AB02CED4FF10071C7DB /* UserDefaultsFireproofingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BD5123D8C2220057FDD2 /* UserDefaultsFireproofingTests.swift */; }; 98424AB22CEDD6150071C7DB /* BrowserServicesKit in Frameworks */ = {isa = PBXBuildFile; productRef = 98424AB12CEDD6150071C7DB /* BrowserServicesKit */; }; 98424AB42CEDD61C0071C7DB /* BrowserServicesKitTestsUtils in Frameworks */ = {isa = PBXBuildFile; productRef = 98424AB32CEDD61C0071C7DB /* BrowserServicesKitTestsUtils */; }; 9847C00027A2DDBB00DB07AA /* AppPrivacyConfigurationDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9847BFFF27A2DDBB00DB07AA /* AppPrivacyConfigurationDataProvider.swift */; }; @@ -1698,12 +1698,13 @@ 6FEC0B842C999352006B4F6E /* FavoriteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteItem.swift; sourceTree = ""; }; 6FEC0B872C999961006B4F6E /* FavoritesListInteractingAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesListInteractingAdapter.swift; sourceTree = ""; }; 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdAttributionFetcherTests.swift; sourceTree = ""; }; - 6FF9AD442CE766F700C5A406 /* NewTabPageControllerPixelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageControllerPixelTests.swift; sourceTree = ""; }; 6FF9AD3E2CE63DC200C5A406 /* TabSwitcherOpenDailyPixel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSwitcherOpenDailyPixel.swift; sourceTree = ""; }; 6FF9AD402CE6610600C5A406 /* TabSwitcherDailyPixelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSwitcherDailyPixelTests.swift; sourceTree = ""; }; + 6FF9AD442CE766F700C5A406 /* NewTabPageControllerPixelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageControllerPixelTests.swift; sourceTree = ""; }; 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+TipKit.swift"; sourceTree = ""; }; 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TipKitController+ConvenienceInitializers.swift"; sourceTree = ""; }; 7B1604ED2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipKitDebugOptionsUIActionHandling.swift; sourceTree = ""; }; + 7B1C892B2CF714AA0008224E /* VPNTipsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNTipsModel.swift; sourceTree = ""; }; 7B8E0EC52CC81B4800B2B722 /* TipKitController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipKitController.swift; sourceTree = ""; }; 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VPNActivationDateStore.swift; sourceTree = ""; }; 7BDBAD0D2CBFB3F1000379B7 /* VPN.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = VPN.xcassets; sourceTree = ""; }; @@ -4785,7 +4786,7 @@ isa = PBXGroup; children = ( 834DF990248FDDF60075EA48 /* UserAgentTests.swift */, - 8540BD5123D8C2220057FDD2 /* PreserveLoginsTests.swift */, + 8540BD5123D8C2220057FDD2 /* UserDefaultsFireproofingTests.swift */, 850559D123CF710C0055C0D5 /* WebCacheManagerTests.swift */, 981C49AF2C8FA61D00DF11E8 /* DataStoreIdManagerTests.swift */, F198D7971E3A45D90088DA8A /* WKWebViewConfigurationExtensionTests.swift */, @@ -5729,6 +5730,7 @@ children = ( EE4FB1852A28CE7200E5CBA7 /* NetworkProtectionStatusView.swift */, EE4FB1872A28D11900E5CBA7 /* NetworkProtectionStatusViewModel.swift */, + 7B1C892B2CF714AA0008224E /* VPNTipsModel.swift */, ); name = Status; sourceTree = ""; @@ -7966,6 +7968,7 @@ 1E908BF129827C480008C8F3 /* AutoconsentUserScript.swift in Sources */, 1D200C972BA3157A00108701 /* SettingsNextStepsView.swift in Sources */, 4B0295192537BC6700E00CEF /* ConfigurationDebugViewController.swift in Sources */, + 7B1C892C2CF714AA0008224E /* VPNTipsModel.swift in Sources */, 1E7A71192934EC6100B7EA19 /* OmniBarNotificationContainerView.swift in Sources */, D60B1F272B9DDE5A00AE4760 /* SubscriptionGoogleView.swift in Sources */, 984D035C24AE15CD0066CFB8 /* TabSwitcherSettings.swift in Sources */, @@ -8390,7 +8393,7 @@ 98424AAD2CED4FF10071C7DB /* WKWebViewConfigurationExtensionTests.swift in Sources */, 98424AAE2CED4FF10071C7DB /* WebCacheManagerTests.swift in Sources */, 98424AAF2CED4FF10071C7DB /* UserAgentTests.swift in Sources */, - 98424AB02CED4FF10071C7DB /* PreserveLoginsTests.swift in Sources */, + 98424AB02CED4FF10071C7DB /* UserDefaultsFireproofingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DuckDuckGo/NetworkProtectionStatusView.swift b/DuckDuckGo/NetworkProtectionStatusView.swift index 5e03d70ffe..761aa4e1fc 100644 --- a/DuckDuckGo/NetworkProtectionStatusView.swift +++ b/DuckDuckGo/NetworkProtectionStatusView.swift @@ -31,48 +31,9 @@ struct NetworkProtectionStatusView: View { @ObservedObject public var statusModel: NetworkProtectionStatusViewModel - // MARK: - Tips - - let geoswitchingTip: VPNGeoswitchingTip = { - let tip = VPNGeoswitchingTip() - - if #available(iOS 17.0, *) { - if tip.shouldDisplay { - Task { - for await status in tip.statusUpdates { - if case .invalidated = status { - await VPNSnoozeTip.geolocationTipDismissedEvent.donate() - await VPNAddWidgetTip.geolocationTipDismissedEvent.donate() - } - } - } - } - } - - return tip - }() - - let snoozeTip: VPNSnoozeTip = { - let tip = VPNSnoozeTip() - - if #available(iOS 17.0, *) { - if tip.shouldDisplay { - Task { - for await status in tip.statusUpdates { - if case .invalidated = status { - await VPNAddWidgetTip.snoozeTipDismissedEvent.donate() - } - } - } - } - } - - return tip - }() - - let widgetTip: VPNAddWidgetTip = { - VPNAddWidgetTip() - }() + var tipsModel: VPNTipsModel { + statusModel.tipsModel + } // MARK: - View @@ -106,6 +67,16 @@ struct NetworkProtectionStatusView: View { .sheet(isPresented: $statusModel.showAddWidgetEducationView) { widgetEducationSheet() } + .onAppear { + if #available(iOS 18.0, *) { + tipsModel.handleStatusViewAppear() + } + } + .onDisappear { + if #available(iOS 18.0, *) { + tipsModel.handleStatusViewDisappear() + } + } } @ViewBuilder @@ -359,11 +330,26 @@ struct NetworkProtectionStatusView: View { @ViewBuilder private func geoswitchingTipView() -> some View { if statusModel.canShowTips { - - TipView(geoswitchingTip) + TipView(tipsModel.geoswitchingTip) .removeGroupedListStyleInsets() .tipCornerRadius(0) .tipBackground(Color(designSystemColor: .surface)) + .onAppear { + tipsModel.handleGeoswitchingTipShown() + } + .task { + var previousStatus = tipsModel.geoswitchingTip.status + + for await status in tipsModel.geoswitchingTip.statusUpdates { + if case .invalidated(let reason) = status { + if case .available = previousStatus { + tipsModel.handleGeoswitchingTipInvalidated(reason) + } + } + + previousStatus = status + } + } } } @@ -373,10 +359,26 @@ struct NetworkProtectionStatusView: View { if statusModel.canShowTips, statusModel.hasServerInfo { - TipView(snoozeTip, action: statusModel.snoozeActionHandler(action:)) + TipView(tipsModel.snoozeTip, action: statusModel.snoozeActionHandler(action:)) .removeGroupedListStyleInsets() .tipCornerRadius(0) .tipBackground(Color(designSystemColor: .surface)) + .onAppear { + tipsModel.handleSnoozeTipShown() + } + .task { + var previousStatus = tipsModel.snoozeTip.status + + for await status in tipsModel.snoozeTip.statusUpdates { + if case .invalidated(let reason) = status { + if case .available = previousStatus { + tipsModel.handleSnoozeTipInvalidated(reason) + } + } + + previousStatus = status + } + } } } @@ -386,10 +388,26 @@ struct NetworkProtectionStatusView: View { if statusModel.canShowTips, !statusModel.isNetPEnabled && !statusModel.isSnoozing { - TipView(widgetTip, action: statusModel.widgetActionHandler(action:)) + TipView(tipsModel.widgetTip, action: statusModel.widgetActionHandler(action:)) .removeGroupedListStyleInsets() .tipCornerRadius(0) .tipBackground(Color(designSystemColor: .surface)) + .onAppear { + tipsModel.handleWidgetTipShown() + } + .task { + var previousStatus = tipsModel.widgetTip.status + + for await status in tipsModel.widgetTip.statusUpdates { + if case .invalidated(let reason) = status { + if case .available = previousStatus { + tipsModel.handleWidgetTipInvalidated(reason) + } + } + + previousStatus = status + } + } } } diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index becccb79aa..16f0904f33 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -113,6 +113,8 @@ final class NetworkProtectionStatusViewModel: ObservableObject { @Published var showAddWidgetEducationView: Bool = false + let tipsModel: VPNTipsModel + // MARK: Error struct ErrorItem { @@ -138,7 +140,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { didSet { if #available(iOS 17.0, *) { if isNetPEnabled { - VPNGeoswitchingTip.donateVPNConnectedEvent() + VPNGeoswitchingTip.vpnEnabledOnce = true } VPNSnoozeTip.vpnEnabled = isNetPEnabled @@ -184,6 +186,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { errorObserver: ConnectionErrorObserver = ConnectionErrorObserverThroughSession(), locationListRepository: NetworkProtectionLocationListRepository, usesUnifiedFeedbackForm: Bool) { + self.tunnelController = tunnelController self.settings = settings self.statusObserver = statusObserver @@ -199,6 +202,11 @@ final class NetworkProtectionStatusViewModel: ObservableObject { self.dnsSettings = settings.dnsSettings + self.tipsModel = VPNTipsModel( + isTipFeatureEnabled: featureFlagger.isFeatureOn(.networkProtectionUserTips), + statusObserver: statusObserver, + vpnSettings: settings) + updateViewModel(withStatus: statusObserver.recentValue) setUpIsConnectedStatePublishers() @@ -477,8 +485,8 @@ final class NetworkProtectionStatusViewModel: ObservableObject { return } - if #available(iOS 17.0, *) { - VPNSnoozeTip().invalidate(reason: .actionPerformed) + if #available(iOS 18.0, *) { + tipsModel.handleUserSnoozedVPN() } let defaultDuration: TimeInterval = .minutes(20) @@ -577,7 +585,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { // MARK: - UI Events handling - @available(iOS 17.0, *) + @available(iOS 18.0, *) func snoozeActionHandler(action: Tips.Action) { if action.id == VPNSnoozeTip.ActionIdentifiers.learnMore.rawValue { let url = URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/vpn/troubleshooting/")! @@ -585,22 +593,22 @@ final class NetworkProtectionStatusViewModel: ObservableObject { } } - @available(iOS 17.0, *) + @available(iOS 18.0, *) @MainActor func widgetActionHandler(action: Tips.Action) { if action.id == VPNAddWidgetTip.ActionIdentifiers.addWidget.rawValue { showAddWidgetEducationView = true - VPNAddWidgetTip().invalidate(reason: .actionPerformed) + tipsModel.handleUserOpenedWidgetLearnMore() } } /// The user opened the VPN locations view /// func handleUserOpenedVPNLocations() { - if #available(iOS 17.0, *) { + if #available(iOS 18.0, *) { Task { @MainActor in - VPNGeoswitchingTip().invalidate(reason: .actionPerformed) + tipsModel.handleUserOpenedLocations() } } } diff --git a/DuckDuckGo/VPNAddWidgetTip.swift b/DuckDuckGo/VPNAddWidgetTip.swift index fd6f652415..8abedf86a9 100644 --- a/DuckDuckGo/VPNAddWidgetTip.swift +++ b/DuckDuckGo/VPNAddWidgetTip.swift @@ -32,9 +32,14 @@ extension VPNAddWidgetTip: Tip { case addWidget = "com.duckduckgo.vpn.tip.addWidget.action.addWidget" } - static let geolocationTipDismissedEvent = Tips.Event(id: "com.duckduckgo.vpn.tip.addWidget.geolocationTipDismissedEvent") - - static let snoozeTipDismissedEvent = Tips.Event(id: "com.duckduckgo.vpn.tip.addWidget.geolocationTipDismissedEvent") + /// This condition tries to verify that this tip is distanced from the previous tip.. + /// + /// The conditions that will trigger this are: + /// - The status view was opened when previous tip's status is invalidated. + /// - The VPN is enabled when previous tip's status is invalidated. + /// + @Parameter + static var isDistancedFromPreviousTip: Bool = false @Parameter(.transient) static var vpnEnabled: Bool = false @@ -65,14 +70,11 @@ extension VPNAddWidgetTip: Tip { } var rules: [Rule] { - #Rule(Self.geolocationTipDismissedEvent) { - $0.donations.count > 0 - } - #Rule(Self.snoozeTipDismissedEvent) { - $0.donations.count > 0 - } #Rule(Self.$vpnEnabled) { $0 == false } + #Rule(Self.$isDistancedFromPreviousTip) { + $0 + } } } diff --git a/DuckDuckGo/VPNGeoswitchingTip.swift b/DuckDuckGo/VPNGeoswitchingTip.swift index e8110fa18c..e2c3fe0b21 100644 --- a/DuckDuckGo/VPNGeoswitchingTip.swift +++ b/DuckDuckGo/VPNGeoswitchingTip.swift @@ -26,7 +26,12 @@ struct VPNGeoswitchingTip {} @available(iOS 17.0, *) extension VPNGeoswitchingTip: Tip { - private static let vpnConnectedEvent = Tips.Event(id: "com.duckduckgo.vpn.tip.geoswitching.vpnConnectedEvent") + /// Where the VPN was ever enabled. + /// + /// Once set this is never unset. The tip doesn't need to be hidden when the user is disconnected. + /// + @Parameter + static var vpnEnabledOnce: Bool = false var id: String { "com.duckduckgo.vpn.tip.geoswitching" @@ -45,14 +50,8 @@ extension VPNGeoswitchingTip: Tip { } var rules: [Rule] { - #Rule(Self.vpnConnectedEvent) { - $0.donations.donatedWithin(.week).count > 0 - } - } - - static func donateVPNConnectedEvent() { - Task { - await vpnConnectedEvent.donate() + #Rule(Self.$vpnEnabledOnce) { + $0 } } } diff --git a/DuckDuckGo/VPNSnoozeTip.swift b/DuckDuckGo/VPNSnoozeTip.swift index 9baa7bacba..aa4ab1f72a 100644 --- a/DuckDuckGo/VPNSnoozeTip.swift +++ b/DuckDuckGo/VPNSnoozeTip.swift @@ -32,7 +32,14 @@ extension VPNSnoozeTip: Tip { case learnMore = "com.duckduckgo.vpn.tip.snooze.learnMoreId" } - static let geolocationTipDismissedEvent = Tips.Event(id: "com.duckduckgo.vpn.tip.snooze.geolocationTipDismissedEvent") + /// This condition tries to verify that this tip is distanced from the previous tip.. + /// + /// The conditions that will trigger this are: + /// - The status view was opened when previous tip's status is invalidated. + /// - The VPN is enabled when previous tip's status is invalidated. + /// + @Parameter + static var isDistancedFromPreviousTip: Bool = false @Parameter(.transient) static var vpnEnabled: Bool = false @@ -61,11 +68,11 @@ extension VPNSnoozeTip: Tip { } var rules: [Rule] { - #Rule(Self.geolocationTipDismissedEvent) { - $0.donations.count > 0 - } #Rule(Self.$vpnEnabled) { $0 == true } + #Rule(Self.$isDistancedFromPreviousTip) { + $0 + } } } diff --git a/DuckDuckGo/VPNTipsModel.swift b/DuckDuckGo/VPNTipsModel.swift new file mode 100644 index 0000000000..5cdcf49fa0 --- /dev/null +++ b/DuckDuckGo/VPNTipsModel.swift @@ -0,0 +1,244 @@ +// +// VPNTipsModel.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 Combine +import Common +import Core +import NetworkProtection +import os.log +import TipKit + +public final class VPNTipsModel: ObservableObject { + + static let imageSize = CGSize(width: 32, height: 32) + + @Published + private(set) var connectionStatus: ConnectionStatus { + didSet { + guard #available(iOS 18.0, *) else { + return + } + + handleConnectionStatusChanged(oldValue: oldValue, newValue: connectionStatus) + } + } + + private var isTipFeatureEnabled: Bool + private let vpnSettings: VPNSettings + private var cancellables = Set() + + public init(isTipFeatureEnabled: Bool, + statusObserver: ConnectionStatusObserver, + vpnSettings: VPNSettings) { + + self.connectionStatus = statusObserver.recentValue + self.isTipFeatureEnabled = isTipFeatureEnabled + self.vpnSettings = vpnSettings + + if #available(iOS 18.0, *) { + handleConnectionStatusChanged(oldValue: connectionStatus, newValue: connectionStatus) + + subscribeToConnectionStatusChanges(statusObserver) + } + } + + deinit { + geoswitchingStatusUpdateTask?.cancel() + geoswitchingStatusUpdateTask = nil + } + + var canShowTips: Bool { + isTipFeatureEnabled + } + + // MARK: - Subscriptions + + @available(iOS 18.0, *) + private func subscribeToConnectionStatusChanges(_ statusObserver: ConnectionStatusObserver) { + statusObserver.publisher + .removeDuplicates() + .receive(on: DispatchQueue.main) + .assign(to: \.connectionStatus, onWeaklyHeld: self) + .store(in: &cancellables) + } + + // MARK: - Tips + + let geoswitchingTip = VPNGeoswitchingTip() + let snoozeTip = VPNSnoozeTip() + let widgetTip = VPNAddWidgetTip() + + var geoswitchingStatusUpdateTask: Task? + + // MARK: - Tip Action handling + + @available(iOS 18.0, *) + func snoozeTipActionHandler(_ action: Tip.Action) { + if action.id == VPNSnoozeTip.ActionIdentifiers.learnMore.rawValue { + vpnSettings.connectOnLogin = true + + snoozeTip.invalidate(reason: .actionPerformed) + } + } + + // MARK: - Handle Refreshing + + @available(iOS 18.0, *) + private func handleConnectionStatusChanged(oldValue: ConnectionStatus, newValue: ConnectionStatus) { + switch newValue { + case .connected: + if case oldValue = .connecting { + handleTipDistanceConditionsCheckpoint() + } + + VPNAddWidgetTip.vpnEnabled = true + VPNGeoswitchingTip.vpnEnabledOnce = true + VPNSnoozeTip.vpnEnabled = true + default: + if case oldValue = .disconnecting { + handleTipDistanceConditionsCheckpoint() + } + + VPNAddWidgetTip.vpnEnabled = false + VPNSnoozeTip.vpnEnabled = false + } + } + + @available(iOS 18.0, *) + private func handleTipDistanceConditionsCheckpoint() { + if case .invalidated = geoswitchingTip.status { + VPNAddWidgetTip.isDistancedFromPreviousTip = true + } + + if case .invalidated = widgetTip.status { + VPNSnoozeTip.isDistancedFromPreviousTip = true + } + } + + // MARK: - UI Events + + @available(iOS 18.0, *) + func handleGeoswitchingTipInvalidated(_ reason: Tip.InvalidationReason) { + switch reason { + case .actionPerformed: + Pixel.fire(pixel: .networkProtectionGeoswitchingTipActioned, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + default: + Pixel.fire(pixel: .networkProtectionGeoswitchingTipDismissed, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + } + } + + @available(iOS 18.0, *) + func handleSnoozeTipInvalidated(_ reason: Tip.InvalidationReason) { + switch reason { + case .actionPerformed: + Pixel.fire(pixel: .networkProtectionSnoozeTipActioned, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + default: + Pixel.fire(pixel: .networkProtectionSnoozeTipDismissed, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + } + } + + @available(iOS 18.0, *) + func handleWidgetTipInvalidated(_ reason: Tip.InvalidationReason) { + switch reason { + case .actionPerformed: + Pixel.fire(pixel: .networkProtectionWidgetTipActioned, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + default: + Pixel.fire(pixel: .networkProtectionWidgetTipDismissed, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + } + } + + // MARK: - User Actions + + @available(iOS 18.0, *) + func handleUserOpenedWidgetLearnMore() { + widgetTip.invalidate(reason: .actionPerformed) + } + + @available(iOS 18.0, *) + func handleUserOpenedLocations() { + geoswitchingTip.invalidate(reason: .actionPerformed) + } + + @available(iOS 18.0, *) + func handleUserSnoozedVPN() { + snoozeTip.invalidate(reason: .actionPerformed) + } + + // MARK: - Status View UI Events + + @available(iOS 18.0, *) + func handleStatusViewAppear() { + handleTipDistanceConditionsCheckpoint() + } + + @available(iOS 18.0, *) + func handleStatusViewDisappear() { + + if case .available = geoswitchingTip.status { + Pixel.fire(pixel: .networkProtectionGeoswitchingTipIgnored, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + } + + if case .available = snoozeTip.status { + Pixel.fire(pixel: .networkProtectionSnoozeTipIgnored, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + } + + if case .available = widgetTip.status { + Pixel.fire(pixel: .networkProtectionWidgetTipIgnored, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + } + } + + @available(iOS 18.0, *) + func handleGeoswitchingTipShown() { + Pixel.fire(pixel: .networkProtectionGeoswitchingTipShown, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + } + + @available(iOS 18.0, *) + func handleSnoozeTipShown() { + Pixel.fire(pixel: .networkProtectionSnoozeTipShown, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + } + + @available(iOS 18.0, *) + func handleWidgetTipShown() { + Pixel.fire(pixel: .networkProtectionWidgetTipShown, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) + } +}