From 73162b4d50afd069868449969a5252c77cee4863 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 11 Aug 2024 21:42:55 -0700 Subject: [PATCH] VPN snooze mode (#3184) Task/Issue URL: https://app.asana.com/0/72649045549333/1207974416599035/f Tech Design URL: CC: Description: This PR adds VPN snooze mode. --- ...workProtectionNotificationIdentifier.swift | 1 + Core/PixelEvent.swift | 8 + DuckDuckGo.xcodeproj/project.pbxproj | 28 +++- .../xcshareddata/swiftpm/Package.resolved | 4 +- DuckDuckGo/AppDelegate.swift | 4 + DuckDuckGo/Info.plist | 2 + DuckDuckGo/LottieView.swift | 31 +++- ...orkProtectionConvenienceInitialisers.swift | 1 + .../NetworkProtectionDebugUtilities.swift | 10 ++ ...NetworkProtectionDebugViewController.swift | 7 + DuckDuckGo/NetworkProtectionStatusView.swift | 50 ++++-- .../NetworkProtectionStatusViewModel.swift | 126 +++++++++++++- .../NetworkProtectionTunnelController.swift | 10 ++ DuckDuckGo/SettingsViewModel.swift | 6 +- DuckDuckGo/UserText.swift | 30 +++- DuckDuckGo/VPNIntents.swift | 58 +++++++ DuckDuckGo/VPNSnoozeActivityAttributes.swift | 30 ++++ DuckDuckGo/VPNSnoozeLiveActivityManager.swift | 72 ++++++++ DuckDuckGo/VPNSnoozeLiveActivityWidget.swift | 157 ++++++++++++++++++ DuckDuckGo/en.lproj/Localizable.strings | 24 +++ ...etworkProtectionStatusViewModelTests.swift | 6 +- ...etworkProtectionPacketTunnelProvider.swift | 1 + ...orkProtectionUNNotificationPresenter.swift | 16 +- PacketTunnelProvider/UserText.swift | 19 +++ .../en.lproj/Localizable.strings | 6 + .../Contents.json | 38 +++++ .../vpn-off-compact.imageset/Contents.json | 12 ++ .../vpn-off-compact.pdf | Bin 0 -> 1251 bytes .../Contents.json | 12 ++ .../vpn-off-live-activity.pdf | Bin 0 -> 6356 bytes .../vpn-on-compact.imageset/Contents.json | 12 ++ .../vpn-on-compact.pdf | Bin 0 -> 1160 bytes Widgets/UserText.swift | 24 +++ Widgets/VPNWidget.swift | 70 +++++++- Widgets/Widgets.swift | 15 +- Widgets/en.lproj/Localizable.strings | 18 ++ 36 files changed, 861 insertions(+), 47 deletions(-) create mode 100644 DuckDuckGo/VPNSnoozeActivityAttributes.swift create mode 100644 DuckDuckGo/VPNSnoozeLiveActivityManager.swift create mode 100644 DuckDuckGo/VPNSnoozeLiveActivityWidget.swift create mode 100644 Widgets/Assets.xcassets/DeprecatedColors/WidgetLiveActivityButtonColor.colorset/Contents.json create mode 100644 Widgets/Assets.xcassets/vpn-off-compact.imageset/Contents.json create mode 100644 Widgets/Assets.xcassets/vpn-off-compact.imageset/vpn-off-compact.pdf create mode 100644 Widgets/Assets.xcassets/vpn-off-live-activity.imageset/Contents.json create mode 100644 Widgets/Assets.xcassets/vpn-off-live-activity.imageset/vpn-off-live-activity.pdf create mode 100644 Widgets/Assets.xcassets/vpn-on-compact.imageset/Contents.json create mode 100644 Widgets/Assets.xcassets/vpn-on-compact.imageset/vpn-on-compact.pdf diff --git a/Core/NetworkProtectionNotificationIdentifier.swift b/Core/NetworkProtectionNotificationIdentifier.swift index 5ac235621c..9832a07584 100644 --- a/Core/NetworkProtectionNotificationIdentifier.swift +++ b/Core/NetworkProtectionNotificationIdentifier.swift @@ -24,4 +24,5 @@ public enum NetworkProtectionNotificationIdentifier: String { case superseded = "network-protection.notification.superseded" case test = "network-protection.notification.test" case entitlement = "network-protection.notification.entitlement" + case snoozeEnded = "network-protection.notification.snooze-ended" } diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 4b87409714..57191192a4 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -427,6 +427,10 @@ extension Pixel { case networkProtectionGeoswitchingSetCustom case networkProtectionGeoswitchingNoLocations + case networkProtectionSnoozeEnabledFromStatusMenu + case networkProtectionSnoozeDisabledFromStatusMenu + case networkProtectionSnoozeDisabledFromLiveActivity + case networkProtectionFailureRecoveryStarted case networkProtectionFailureRecoveryFailed case networkProtectionFailureRecoveryCompletedHealthy @@ -1150,6 +1154,10 @@ extension Pixel.Event { case .networkProtectionGeoswitchingSetCustom: return "m_netp_ev_geoswitching_set_custom" case .networkProtectionGeoswitchingNoLocations: return "m_netp_ev_geoswitching_no_locations" + case .networkProtectionSnoozeEnabledFromStatusMenu: return "m_netp_snooze_enabled_status_menu" + case .networkProtectionSnoozeDisabledFromStatusMenu: return "m_netp_snooze_disabled_status_menu" + case .networkProtectionSnoozeDisabledFromLiveActivity: return "m_netp_snooze_disabled_live_activity" + case .networkProtectionClientFailedToFetchServerStatus: return "m_netp_server_migration_failed_to_fetch_status" case .networkProtectionClientFailedToParseServerStatusResponse: return "m_netp_server_migration_failed_to_parse_response" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 46ddcde01b..9b3e0ba22f 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -207,6 +207,7 @@ 4B0295192537BC6700E00CEF /* ConfigurationDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0295182537BC6700E00CEF /* ConfigurationDebugViewController.swift */; }; 4B0F3F502B9BFF2100392892 /* NetworkProtectionFAQView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0F3F4F2B9BFF2100392892 /* NetworkProtectionFAQView.swift */; }; 4B274F602AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B274F5F2AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift */; }; + 4B2C79612C5B27AC00A240CC /* VPNSnoozeActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD96E082C4DCDD2003BC32C /* VPNSnoozeActivityAttributes.swift */; }; 4B37E0502B928CA6009E81CA /* vpn-light-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = 4B37E04F2B928CA6009E81CA /* vpn-light-mode.json */; }; 4B412ACC2BBB3D0900A39F5E /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B412ACB2BBB3D0900A39F5E /* LazyView.swift */; }; 4B45D85C2BE0B115006061B5 /* Subscription in Frameworks */ = {isa = PBXBuildFile; productRef = 4B45D85B2BE0B115006061B5 /* Subscription */; }; @@ -229,6 +230,11 @@ 4BCBE45E2BA7E81F00FC75A1 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 4BCBE45D2BA7E81F00FC75A1 /* PrivacyInfo.xcprivacy */; }; 4BCBE4602BA7E87100FC75A1 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 4BCBE45F2BA7E87100FC75A1 /* PrivacyInfo.xcprivacy */; }; 4BCD14672B05B682000B1E4C /* NetworkProtectionTermsAndConditionsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCD14662B05B682000B1E4C /* NetworkProtectionTermsAndConditionsStore.swift */; }; + 4BD96E062C4DBC93003BC32C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02025663298818B100E694E7 /* NetworkExtension.framework */; }; + 4BD96E0B2C4DCE55003BC32C /* VPNSnoozeLiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD96E0A2C4DCE55003BC32C /* VPNSnoozeLiveActivityManager.swift */; }; + 4BD96E0E2C4DCFD7003BC32C /* VPNSnoozeLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD96E0C2C4DCEAA003BC32C /* VPNSnoozeLiveActivityWidget.swift */; }; + 4BD96E0F2C4DCFEB003BC32C /* VPNSnoozeActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD96E082C4DCDD2003BC32C /* VPNSnoozeActivityAttributes.swift */; }; + 4BD96E102C4DF329003BC32C /* VPNSnoozeLiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD96E0A2C4DCE55003BC32C /* VPNSnoozeLiveActivityManager.swift */; }; 4BE2756827304F57006B20B0 /* URLRequestExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE27566272F878F006B20B0 /* URLRequestExtension.swift */; }; 4BE67B012B96B741007335F7 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 4BE67B002B96B741007335F7 /* Common */; }; 4BE67B032B96B864007335F7 /* ContentBlocking in Frameworks */ = {isa = PBXBuildFile; productRef = 4BE67B022B96B864007335F7 /* ContentBlocking */; }; @@ -1439,6 +1445,9 @@ 4BCBE45D2BA7E81F00FC75A1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 4BCBE45F2BA7E87100FC75A1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 4BCD14662B05B682000B1E4C /* NetworkProtectionTermsAndConditionsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionTermsAndConditionsStore.swift; sourceTree = ""; }; + 4BD96E082C4DCDD2003BC32C /* VPNSnoozeActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSnoozeActivityAttributes.swift; sourceTree = ""; }; + 4BD96E0A2C4DCE55003BC32C /* VPNSnoozeLiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSnoozeLiveActivityManager.swift; sourceTree = ""; }; + 4BD96E0C2C4DCEAA003BC32C /* VPNSnoozeLiveActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSnoozeLiveActivityWidget.swift; sourceTree = ""; }; 4BE27566272F878F006B20B0 /* URLRequestExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = URLRequestExtension.swift; path = ../DuckDuckGo/URLRequestExtension.swift; sourceTree = ""; }; 4BF3E4AE2C06A85200ED5D57 /* VPNRedditSessionWorkaround.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNRedditSessionWorkaround.swift; sourceTree = ""; }; 560E990E2BEE2CB800507CE0 /* SyncErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncErrorMessage.swift; sourceTree = ""; }; @@ -2923,6 +2932,7 @@ buildActionMask = 2147483647; files = ( 8512EA5124ED30D20073EE19 /* SwiftUI.framework in Frameworks */, + 4BD96E062C4DBC93003BC32C /* NetworkExtension.framework in Frameworks */, 85DF714624F7FE6100C89288 /* Core.framework in Frameworks */, 8512EA4F24ED30D20073EE19 /* WidgetKit.framework in Frameworks */, 4BBBBA872B02E85400D965DA /* DesignResourcesKit in Frameworks */, @@ -3554,6 +3564,16 @@ name = VPN; sourceTree = ""; }; + 4BD96E072C4DCCD1003BC32C /* LiveActivity */ = { + isa = PBXGroup; + children = ( + 4BD96E082C4DCDD2003BC32C /* VPNSnoozeActivityAttributes.swift */, + 4BD96E0A2C4DCE55003BC32C /* VPNSnoozeLiveActivityManager.swift */, + 4BD96E0C2C4DCEAA003BC32C /* VPNSnoozeLiveActivityWidget.swift */, + ); + name = LiveActivity; + sourceTree = ""; + }; 566B736E2BECD3DC00FF1959 /* Utilities */ = { isa = PBXGroup; children = ( @@ -5200,6 +5220,7 @@ EECD94B22A28B8580085C66E /* NetworkProtection */ = { isa = PBXGroup; children = ( + 4BD96E072C4DCCD1003BC32C /* LiveActivity */, 4B37E04E2B928C91009E81CA /* Resources */, EE01EB412AFC1DE10096AAC9 /* PreferredLocation */, EE9D68CF2AE00CE000B55EF4 /* VPNSettings */, @@ -7022,6 +7043,7 @@ 98D98A7425ED88D100D8E3DF /* BrowsingMenuEntryViewCell.swift in Sources */, F1564F032B7B915F00D454A6 /* AppDelegate+SKAD4.swift in Sources */, 98F3A1D8217B37010011A0D4 /* Theme.swift in Sources */, + 4B2C79612C5B27AC00A240CC /* VPNSnoozeActivityAttributes.swift in Sources */, CB9B873C278C8FEA001F4906 /* WidgetEducationView.swift in Sources */, 85F200002215C17B006BB258 /* FindInPage.swift in Sources */, F1386BA41E6846C40062FC3C /* TabDelegate.swift in Sources */, @@ -7189,6 +7211,7 @@ D62EC3C22C248AF800FC9D04 /* DuckNavigationHandling.swift in Sources */, 9FB027142C252E0C009EA190 /* OnboardingView+BrowsersComparisonContent.swift in Sources */, D664C7B62B289AA200CBFA76 /* SubscriptionFlowViewModel.swift in Sources */, + 4BD96E0B2C4DCE55003BC32C /* VPNSnoozeLiveActivityManager.swift in Sources */, 1EFDCBC127D2393C00916BC5 /* DownloadsDeleteHelper.swift in Sources */, C1836CE12C359EC90016D057 /* AutofillBreakageReportCellContentView.swift in Sources */, 6F9FFE282C579DEA00A238BE /* NewTabPageSectionsSettingsStorage.swift in Sources */, @@ -7658,7 +7681,9 @@ buildActionMask = 2147483647; files = ( 853273AE24FEF49600E3C778 /* ColorExtension.swift in Sources */, + 4BD96E0F2C4DCFEB003BC32C /* VPNSnoozeActivityAttributes.swift in Sources */, 373608932ABB432600629E7F /* FavoritesDisplayMode+UserDefaults.swift in Sources */, + 4BD96E102C4DF329003BC32C /* VPNSnoozeLiveActivityManager.swift in Sources */, 853273B324FF114700E3C778 /* DeepLinks.swift in Sources */, 853273B424FFB36100E3C778 /* UIColorExtension.swift in Sources */, 853273AB24FEF27500E3C778 /* WidgetViews.swift in Sources */, @@ -7666,6 +7691,7 @@ 4BB7CBB02AF59C310014A35F /* VPNWidget.swift in Sources */, 8512EA5424ED30D20073EE19 /* Widgets.swift in Sources */, 85DB12EB2A1FE2A4000A4A72 /* LockScreenWidgets.swift in Sources */, + 4BD96E0E2C4DCFD7003BC32C /* VPNSnoozeLiveActivityWidget.swift in Sources */, 8544C37C250B827300A0FE73 /* UserText.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -10481,7 +10507,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 182.0.0; + version = 183.0.0; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0e95a916c7..30754477e9 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "d67979814bdd3c4c43a38e6694c56f1fdb5969ac", - "version" : "182.0.0" + "revision" : "c3ae1865ba36ebbcb5451a836424213ea875d135", + "version" : "183.0.0" } }, { diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index ac22078290..4ef2c33906 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -548,6 +548,10 @@ import WebKit await stopAndRemoveVPNIfNotAuthenticated() await refreshShortcuts() await vpnWorkaround.installRedditSessionWorkaround() + + if #available(iOS 17.0, *) { + await VPNSnoozeLiveActivityManager().endSnoozeActivityIfNecessary() + } } AppDependencyProvider.shared.subscriptionManager.refreshCachedSubscriptionAndEntitlements { isSubscriptionActive in diff --git a/DuckDuckGo/Info.plist b/DuckDuckGo/Info.plist index f37560b6dc..bd6d42fb7c 100644 --- a/DuckDuckGo/Info.plist +++ b/DuckDuckGo/Info.plist @@ -225,6 +225,8 @@ SUBSCRIPTION_APP_GROUP $(AppIdentifierPrefix)$(SUBSCRIPTION_APP_GROUP) + NSSupportsLiveActivities + UIStatusBarStyle UIStatusBarStyleDefault UISupportedInterfaceOrientations~ipad diff --git a/DuckDuckGo/LottieView.swift b/DuckDuckGo/LottieView.swift index c6143a769f..a3f0a12f59 100644 --- a/DuckDuckGo/LottieView.swift +++ b/DuckDuckGo/LottieView.swift @@ -35,22 +35,24 @@ struct LottieView: UIViewRepresentable { case withIntro(LoopWithIntroTiming) } - let lottieFile: String let delay: TimeInterval var isAnimating: Binding private let loopMode: LoopMode + let animationName: String + let animation: LottieAnimation? let animationView = LottieAnimationView() init(lottieFile: String, delay: TimeInterval = 0, loopMode: LoopMode = .mode(.playOnce), isAnimating: Binding = .constant(true)) { - self.lottieFile = lottieFile + self.animationName = lottieFile + self.animation = LottieAnimation.named(lottieFile) self.delay = delay self.isAnimating = isAnimating self.loopMode = loopMode } func makeUIView(context: Context) -> some LottieAnimationView { - animationView.animation = LottieAnimation.named(lottieFile) + animationView.animation = animation animationView.contentMode = .scaleAspectFit animationView.clipsToBounds = false @@ -68,10 +70,25 @@ struct LottieView: UIViewRepresentable { return } - guard isAnimating.wrappedValue, !uiView.isAnimationPlaying else { return } - - if uiView.loopMode == .playOnce && uiView.currentProgress == 1 { return } - + // If the view is not animating and the progress is 0, apply an animation-specific hack. + // The VPN startup animations have an issue with the initial frame that is introduced when backgrounding and foregrounding the app. + // The issue can be reproduced using the official Lottie SwiftUI wrapped, so instead it is being worked around by resetting the animation + // when appropriate. + if !isAnimating.wrappedValue, uiView.currentProgress == 0 { + if uiView.currentFrame == 0, self.animationName.hasPrefix("vpn-") { + uiView.animation = nil + uiView.animation = self.animation + } + } + + guard isAnimating.wrappedValue, !uiView.isAnimationPlaying else { + return + } + + if uiView.loopMode == .playOnce && uiView.currentProgress == 1 { + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { switch loopMode { case .mode: diff --git a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift index dcc6ded6b9..d44f9dbc10 100644 --- a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift +++ b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift @@ -33,6 +33,7 @@ private class DefaultTunnelSessionProvider: TunnelSessionProvider { extension ConnectionStatusObserverThroughSession { convenience init() { self.init(tunnelSessionProvider: DefaultTunnelSessionProvider(), + platformSnoozeTimingStore: NetworkProtectionSnoozeTimingStore(userDefaults: .networkProtectionGroupDefaults), platformNotificationCenter: .default, platformDidWakeNotification: UIApplication.didBecomeActiveNotification) } diff --git a/DuckDuckGo/NetworkProtectionDebugUtilities.swift b/DuckDuckGo/NetworkProtectionDebugUtilities.swift index 5e1e13c31c..6b29b9ab1e 100644 --- a/DuckDuckGo/NetworkProtectionDebugUtilities.swift +++ b/DuckDuckGo/NetworkProtectionDebugUtilities.swift @@ -68,6 +68,16 @@ final class NetworkProtectionDebugUtilities { } try? await activeSession.sendProviderMessage(message) } + + // MARK: - Snooze + + func startSnooze(duration: TimeInterval) async { + guard let activeSession = await AppDependencyProvider.shared.networkProtectionTunnelController.activeSession() else { + return + } + + try? await activeSession.sendProviderMessage(.startSnooze(duration)) + } } private extension NetworkProtectionSimulationOption { diff --git a/DuckDuckGo/NetworkProtectionDebugViewController.swift b/DuckDuckGo/NetworkProtectionDebugViewController.swift index 511bdd6f83..c48a3e55f3 100644 --- a/DuckDuckGo/NetworkProtectionDebugViewController.swift +++ b/DuckDuckGo/NetworkProtectionDebugViewController.swift @@ -82,6 +82,7 @@ final class NetworkProtectionDebugViewController: UITableViewController { case shutDown case showEntitlementMessaging case resetEntitlementMessaging + case startSnooze } enum NetworkPathRows: Int, CaseIterable { @@ -372,6 +373,8 @@ final class NetworkProtectionDebugViewController: UITableViewController { cell.textLabel?.text = "Show Entitlement Messaging" case .resetEntitlementMessaging: cell.textLabel?.text = "Reset Entitlement Messaging" + case .startSnooze: + cell.textLabel?.text = "Snooze For 30 Seconds" case .none: break } @@ -391,6 +394,10 @@ final class NetworkProtectionDebugViewController: UITableViewController { UserDefaults.networkProtectionGroupDefaults.enableEntitlementMessaging() case .resetEntitlementMessaging: UserDefaults.networkProtectionGroupDefaults.resetEntitlementMessaging() + case .startSnooze: + Task { + await NetworkProtectionDebugUtilities().startSnooze(duration: .seconds(30)) + } case .none: break } diff --git a/DuckDuckGo/NetworkProtectionStatusView.swift b/DuckDuckGo/NetworkProtectionStatusView.swift index f40b663d45..5223aee773 100644 --- a/DuckDuckGo/NetworkProtectionStatusView.swift +++ b/DuckDuckGo/NetworkProtectionStatusView.swift @@ -37,7 +37,7 @@ struct NetworkProtectionStatusView: View { toggle() locationDetails() - if statusModel.isNetPEnabled && statusModel.shouldShowConnectionDetails && statusModel.ipAddress != nil { + if statusModel.isNetPEnabled && statusModel.hasServerInfo && !statusModel.isSnoozing { connectionDetails() } @@ -47,8 +47,8 @@ struct NetworkProtectionStatusView: View { .padding(.top, statusModel.error == nil ? 0 : -20) .if(statusModel.animationsOn, transform: { $0 - .animation(.default, value: statusModel.shouldShowConnectionDetails) - .animation(.default, value: statusModel.shouldShowError) + .animation(.easeOut, value: statusModel.hasServerInfo) + .animation(.easeOut, value: statusModel.shouldShowError) }) .applyInsetGroupedListStyle() } @@ -70,6 +70,7 @@ struct NetworkProtectionStatusView: View { .foregroundColor(.init(designSystemColor: .textSecondary)) } } + .layoutPriority(1) Toggle("", isOn: Binding( get: { statusModel.isNetPEnabled }, @@ -83,6 +84,8 @@ struct NetworkProtectionStatusView: View { .toggleStyle(SwitchToggleStyle(tint: .init(designSystemColor: .accent))) } .padding([.top, .bottom], 2) + + snooze() } header: { header() } @@ -125,10 +128,31 @@ struct NetworkProtectionStatusView: View { } } + @ViewBuilder + private func snooze() -> some View { + if statusModel.isSnoozing { + Button(UserText.netPStatusViewWakeUp) { + Task { + await statusModel.cancelSnooze() + } + } + .tint(Color(designSystemColor: .accent)) + .disabled(statusModel.snoozeRequestPending) + } else if statusModel.hasServerInfo { + Button(UserText.netPStatusViewSnooze) { + Task { + await statusModel.startSnooze() + } + } + .tint(Color(designSystemColor: .accent)) + .disabled(statusModel.snoozeRequestPending) + } + } + @ViewBuilder private func locationDetails() -> some View { - Section { - if let location = statusModel.location { + if !statusModel.isSnoozing, let location = statusModel.location { + Section { var locationAttributedString: AttributedString { var attributedString = AttributedString( statusModel.preferredLocation.isNearest ? "\(location) \(UserText.netPVPNLocationNearest)" : location @@ -143,7 +167,13 @@ struct NetworkProtectionStatusView: View { NavigationLink(destination: NetworkProtectionVPNLocationView()) { NetworkProtectionLocationItemView(title: locationAttributedString, imageName: nil) } - } else { + } header: { + Text(statusModel.isNetPEnabled ? UserText.vpnLocationConnected : UserText.vpnLocationSelected) + .foregroundColor(.init(designSystemColor: .textSecondary)) + } + .listRowBackground(Color(designSystemColor: .surface)) + } else { + Section { let imageName = statusModel.preferredLocation.isNearest ? "VPNLocation" : nil var nearestLocationAttributedString: AttributedString { var attributedString = AttributedString(statusModel.preferredLocation.title) @@ -154,12 +184,12 @@ struct NetworkProtectionStatusView: View { NavigationLink(destination: NetworkProtectionVPNLocationView()) { NetworkProtectionLocationItemView(title: nearestLocationAttributedString, imageName: imageName) } + } header: { + Text(statusModel.isNetPEnabled ? UserText.vpnLocationConnected : UserText.vpnLocationSelected) + .foregroundColor(.init(designSystemColor: .textSecondary)) } - } header: { - Text(statusModel.isNetPEnabled ? UserText.vpnLocationConnected : UserText.vpnLocationSelected) - .foregroundColor(.init(designSystemColor: .textSecondary)) + .listRowBackground(Color(designSystemColor: .surface)) } - .listRowBackground(Color(designSystemColor: .surface)) } @ViewBuilder diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index 140a75e35e..53a444aabf 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -22,6 +22,7 @@ import Combine import NetworkProtection import WidgetKit import BrowserServicesKit +import Core struct NetworkProtectionLocationStatusModel { enum LocationIcon { @@ -78,6 +79,13 @@ final class NetworkProtectionStatusViewModel: ObservableObject { return formatter }() + private static var snoozeRemainingDateFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.zeroFormattingBehavior = .pad + return formatter + }() + private let byteCountFormatter: ByteCountFormatter = { let formatter = ByteCountFormatter() formatter.allowsNonnumericFormatting = false @@ -113,6 +121,13 @@ final class NetworkProtectionStatusViewModel: ObservableObject { // MARK: Toggle Item @Published public var isNetPEnabled = false + @Published public var isSnoozing = false { + didSet { + snoozeRequestPending = false + } + } + + @Published public var snoozeRequestPending = false @Published public var statusMessage: String @Published public var shouldDisableToggle: Bool = false @@ -123,7 +138,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { // MARK: Connection Details - @Published public var shouldShowConnectionDetails: Bool = false + @Published public var hasServerInfo: Bool = false @Published public var location: String? @Published public var ipAddress: String? @Published public var dnsSettings: NetworkProtectionDNSSettings @@ -146,7 +161,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { self.serverInfoObserver = serverInfoObserver self.errorObserver = errorObserver statusMessage = Self.message(for: statusObserver.recentValue) - self.headerTitle = Self.titleText(connected: statusObserver.recentValue.isConnected) + self.headerTitle = Self.titleText(status: statusObserver.recentValue) self.statusImageID = Self.statusImageID(connected: statusObserver.recentValue.isConnected) self.preferredLocation = NetworkProtectionLocationStatusModel(selectedLocation: settings.selectedLocation) @@ -172,7 +187,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { } private func setUpIsConnectedStatePublishers() { - statusObserver.publisher.sink { [weak self] status in + statusObserver.publisher.receive(on: DispatchQueue.main).sink { [weak self] status in self?.updateViewModel(withStatus: status) } .store(in: &cancellables) @@ -194,10 +209,25 @@ final class NetworkProtectionStatusViewModel: ObservableObject { } } .store(in: &cancellables) + + statusObserver.publisher + .map { + switch $0 { + case .snoozing: + return true + default: + return false + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + .assign(to: \.isSnoozing, onWeaklyHeld: self) + .store(in: &cancellables) } private func setUpStatusMessagePublishers() { statusObserver.publisher + .removeDuplicates() .flatMap(maxPublishers: .max(1)) { status in // As soon as the connection status changes, we should update the status message var statusUpdatePublishers = [Just(Self.message(for: status)).eraseToAnyPublisher()] @@ -206,6 +236,14 @@ final class NetworkProtectionStatusViewModel: ObservableObject { // In the case that the status is connected, we should then provide timed updates // If we rely on the timed updates alone, there will be a delay to the initial update statusUpdatePublishers.append(Self.timedConnectedStatusMessagePublisher(forConnectedDate: connectedDate)) + case .snoozing: + let timingStore = NetworkProtectionSnoozeTimingStore(userDefaults: .networkProtectionGroupDefaults) + guard let endDate = timingStore.activeTiming?.endDate else { + break + } + + statusUpdatePublishers = [Just(Self.snoozeDurationRemainingMessage(for: endDate, currentDate: Date())).eraseToAnyPublisher()] + statusUpdatePublishers.append(Self.timedSnoozeDurationRemainingMessagePublisher(forSnoozeEndDate: endDate)) default: break } @@ -264,12 +302,12 @@ final class NetworkProtectionStatusViewModel: ObservableObject { $0.serverAddress != nil || $0.serverLocation != nil } .receive(on: DispatchQueue.main) - .assign(to: \.shouldShowConnectionDetails, onWeaklyHeld: self) + .assign(to: \.hasServerInfo, onWeaklyHeld: self) .store(in: &cancellables) } private func updateViewModel(withStatus connectionStatus: ConnectionStatus) { - self.headerTitle = Self.titleText(connected: connectionStatus.isConnected) + self.headerTitle = Self.titleText(status: connectionStatus) self.statusImageID = Self.statusImageID(connected: connectionStatus.isConnected) if !connectionStatus.isConnected { @@ -380,7 +418,11 @@ final class NetworkProtectionStatusViewModel: ObservableObject { // It makes sense as animations should mostly only happen when a user has interacted. animationsOn = true if enabled { - await enableNetP() + if isSnoozing { + await cancelSnooze() + } else { + await enableNetP() + } } else { await disableNetP() } @@ -398,8 +440,43 @@ final class NetworkProtectionStatusViewModel: ObservableObject { await tunnelController.stop() } - private class func titleText(connected isConnected: Bool) -> String { - isConnected ? UserText.netPStatusHeaderTitleOn : UserText.netPStatusHeaderTitleOff + @MainActor + func startSnooze() async { + guard !snoozeRequestPending, let activeSession = await tunnelController.activeSession() else { + return + } + + let defaultDuration: TimeInterval = .minutes(20) + snoozeRequestPending = true + try? await activeSession.sendProviderMessage(.startSnooze(defaultDuration)) + DailyPixel.fire(pixel: .networkProtectionSnoozeEnabledFromStatusMenu) + + if #available(iOS 17.0, *) { + await VPNSnoozeLiveActivityManager().start(endDate: Date().addingTimeInterval(defaultDuration)) + } + } + + @MainActor + func cancelSnooze() async { + guard !snoozeRequestPending, let activeSession = await tunnelController.activeSession() else { + return + } + + snoozeRequestPending = true + try? await activeSession.sendProviderMessage(.cancelSnooze) + DailyPixel.fire(pixel: .networkProtectionSnoozeDisabledFromStatusMenu) + + if #available(iOS 17.0, *) { + await VPNSnoozeLiveActivityManager().endSnoozeActivity() + } + } + + private class func titleText(status: ConnectionStatus) -> String { + switch status { + case .connected: return UserText.netPStatusHeaderTitleOn + case .snoozing: return UserText.netPStatusHeaderTitleSnoozed + case .notConfigured, .disconnected, .disconnecting, .connecting, .reasserting: return UserText.netPStatusHeaderTitleOff + } } private class func statusImageID(connected isConnected: Bool) -> String { @@ -407,7 +484,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { } private static func timedConnectedStatusMessagePublisher(forConnectedDate connectedDate: Date) -> AnyPublisher { - Timer.publish(every: 1, on: .main, in: .default) + Timer.publish(every: .seconds(1), on: .main, in: .default) .autoconnect() .map { Self.connectedMessage(for: connectedDate, currentDate: $0) @@ -415,6 +492,15 @@ final class NetworkProtectionStatusViewModel: ObservableObject { .eraseToAnyPublisher() } + private static func timedSnoozeDurationRemainingMessagePublisher(forSnoozeEndDate snoozeEndDate: Date) -> AnyPublisher { + Timer.publish(every: .seconds(1), on: .main, in: .default) + .autoconnect() + .map { + Self.snoozeDurationRemainingMessage(for: snoozeEndDate, currentDate: $0) + } + .eraseToAnyPublisher() + } + private static func message(for status: ConnectionStatus) -> String { switch status { case .disconnected, .notConfigured: @@ -425,6 +511,8 @@ final class NetworkProtectionStatusViewModel: ObservableObject { return connectedMessage(for: connectedDate) case .connecting, .reasserting: return UserText.netPStatusConnecting + case .snoozing: + return UserText.netPStatusPaused } } @@ -434,6 +522,17 @@ final class NetworkProtectionStatusViewModel: ObservableObject { return UserText.netPStatusConnected(since: timeLapsed) } + private static func snoozeDurationRemainingMessage(for snoozeEndDate: Date, currentDate: Date = Date()) -> String { + if snoozeEndDate <= currentDate { + return UserText.netPCellSnoozing + } + + let timeRemainingInterval = snoozeEndDate.timeIntervalSince(currentDate) + let timeRemaining = Self.snoozeRemainingDateFormatter.string(from: timeRemainingInterval) ?? "00:00" + + return UserText.netPStatusSnoozing(until: timeRemaining) + } + private func resetConnectionInformation() { self.location = nil self.ipAddress = nil @@ -453,6 +552,15 @@ private extension ConnectionStatus { } } + var isSnoozing: Bool { + switch self { + case .snoozing: + return true + default: + return false + } + } + var isLoading: Bool { switch self { case .connecting, .reasserting, .disconnecting: diff --git a/DuckDuckGo/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtectionTunnelController.swift index 43a047dab6..fe6e1620ee 100644 --- a/DuckDuckGo/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtectionTunnelController.swift @@ -38,6 +38,7 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr private let debugFeatures = NetworkProtectionDebugFeatures() private let tokenStore: NetworkProtectionKeychainTokenStore private let errorStore = NetworkProtectionTunnelErrorStore() + private let snoozeTimingStore = NetworkProtectionSnoozeTimingStore(userDefaults: .networkProtectionGroupDefaults) private let notificationCenter: NotificationCenter = .default private var previousStatus: NEVPNStatus = .invalid private var cancellables = Set() @@ -119,6 +120,7 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr init(accountManager: AccountManager, tokenStore: NetworkProtectionKeychainTokenStore) { self.tokenStore = tokenStore + subscribeToSnoozeTimingChanges() subscribeToStatusChanges() subscribeToConfigurationChanges() } @@ -369,6 +371,14 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr } } + private func subscribeToSnoozeTimingChanges() { + snoozeTimingStore.snoozeTimingChangedSubject + .sink { + NotificationCenter.default.post(name: .VPNSnoozeRefreshed, object: nil) + } + .store(in: &cancellables) + } + // MARK: - On Demand @MainActor diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index 93b4ecdb33..f8dc77a20b 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -462,6 +462,8 @@ extension SettingsViewModel { switch connectionStatus { case .connected: self.state.networkProtection.status = UserText.netPCellConnected + case .snoozing: + self.state.networkProtection.status = UserText.netPCellSnoozing default: self.state.networkProtection.status = UserText.netPCellDisconnected } @@ -479,8 +481,8 @@ extension SettingsViewModel { AppDependencyProvider.shared.connectionObserver.publisher .receive(on: DispatchQueue.main) - .sink { [weak self] hasActiveSubscription in - self?.updateNetPStatus(connectionStatus: hasActiveSubscription) + .sink { [weak self] status in + self?.updateNetPStatus(connectionStatus: status) } .store(in: &cancellables) diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 1bb196c82b..b2e52fd6d4 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -491,7 +491,8 @@ public struct UserText { public static let netPNavTitle = NSLocalizedString("netP.title", value: "DuckDuckGo VPN", comment: "Title for the DuckDuckGo VPN feature") public static let netPCellConnected = NSLocalizedString("netP.cell.connected", value: "Connected", comment: "String indicating NetP is connected when viewed from the settings screen") public static let netPCellDisconnected = NSLocalizedString("netP.cell.disconnected", value: "Not connected", comment: "String indicating NetP is disconnected when viewed from the settings screen") - + public static let netPCellSnoozing = NSLocalizedString("netP.cell.snoozing", value: "Snoozing", comment: "String indicating NetP is snoozing when viewed from the settings screen") + static let netPInviteTitle = NSLocalizedString("network.protection.invite.dialog.title", value: "You’re invited to try DuckDuckGo VPN", comment: "Title for the network protection invite screen") static let netPInviteMessage = NSLocalizedString("network.protection.invite.dialog.message", value: "Enter your invite code to get started.", comment: "Message for the network protection invite dialog") static let netPInviteFieldPrompt = NSLocalizedString("network.protection.invite.field.prompt", value: "Invite Code", comment: "Prompt for the network protection invite code text field") @@ -501,15 +502,23 @@ public struct UserText { static let netPStatusViewTitle = NSLocalizedString("network.protection.status.view.title", value: "VPN", comment: "Title label text for the status view when netP is disconnected") static let netPStatusHeaderTitleOff = NSLocalizedString("network.protection.status.header.title.off", value: "DuckDuckGo VPN is Off", comment: "Header title label text for the status view when VPN is disconnected") static let netPStatusHeaderTitleOn = NSLocalizedString("network.protection.status.header.title.on", value: "DuckDuckGo VPN is On", comment: "Header title label text for the status view when VPN is connected") + static let netPStatusHeaderTitleSnoozed = NSLocalizedString("network.protection.status.header.title.snoozed", value: "DuckDuckGo VPN is Snoozed", comment: "Header title label text for the status view when VPN is snoozing") static let netPStatusHeaderMessageOff = NSLocalizedString("network.protection.status.header.message.off", value: "Connect to secure all of your device’s\nInternet traffic.", comment: "Message label text for the status view when VPN is disconnected") static let netPStatusHeaderMessageOn = NSLocalizedString("network.protection.status.header.message.on", value: "All device Internet traffic is being secured\nthrough the VPN.", comment: "Message label text for the status view when VPN is disconnected") static let netPStatusDisconnected = NSLocalizedString("network.protection.status.disconnected", value: "Not connected", comment: "The label for the NetP VPN when disconnected") static let netPStatusDisconnecting = NSLocalizedString("network.protection.status.disconnecting", value: "Disconnecting...", comment: "The label for the NetP VPN when disconnecting") static let netPStatusConnecting = NSLocalizedString("network.protection.status.connecting", value: "Connecting...", comment: "The label for the NetP VPN when connecting") + static let netPStatusPaused = NSLocalizedString("network.protection.status.paused", value: "Paused", comment: "The label for the NetP VPN when paused") static func netPStatusConnected(since timeLapsedString: String) -> String { let localized = NSLocalizedString("network.protection.status.connected.format", value: "Connected · %@", comment: "The label for when NetP VPN is connected plus the length of time connected as a formatter HH:MM:SS string") return String(format: localized, timeLapsedString) } + static func netPStatusSnoozing(until timeLapsedString: String) -> String { + let localized = NSLocalizedString("network.protection.status.snoozing.format", value: "Snoozing, %@ remaining", comment: "The label for when NetP VPN is snoozing plus the length of time remaining formatted as '0:00'") + return String(format: localized, timeLapsedString) + } + static let netPStatusViewSnooze = NSLocalizedString("network.protection.status.view.action.snooze", value: "Snooze For 20 Minutes", comment: "Snooze button title shown in NetworkProtection's status view.") + static let netPStatusViewWakeUp = NSLocalizedString("network.protection.status.view.action.wake-up", value: "Wake Up", comment: "Wake Up button title shown in NetworkProtection's status view.") static let netPStatusViewLocation = NSLocalizedString("network.protection.status.view.location", value: "Location", comment: "Location label shown in NetworkProtection's status view.") static let netPStatusViewIPAddress = NSLocalizedString("network.protection.status.view.ip.address", value: "IP Address", comment: "IP Address label shown in NetworkProtection's status view.") static let netPStatusViewConnectionDetails = NSLocalizedString("network.protection.status.view.connection.details", value: "Connection Details", comment: "Connection details label shown in NetworkProtection's status view.") @@ -909,11 +918,28 @@ But if you *do* want a peek under the hood, you can find more information about ) return String(format: localized, serverLocation) } + static func networkProtectionSnoozeEndedConnectionSuccessNotificationBody(serverLocation: String) -> String { + let localized = NSLocalizedString( + "network.protection.success.notification.subtitle.snooze.ended.including.serverLocation", + value: "VPN snooze has ended. Routing device traffic through %@.", + comment: "The body of the notification shown when Network Protection connects successfully with the city + state/country as formatted parameter" + ) + return String(format: localized, serverLocation) + } static let networkProtectionConnectionInterruptedNotificationBody = NSLocalizedString("network.protection.interrupted.notification.body", value: "Network Protection was interrupted. Attempting to reconnect now...", comment: "The body of the notification shown when Network Protection's connection is interrupted") static let networkProtectionConnectionFailureNotificationBody = NSLocalizedString("network.protection.failure.notification.body", value: "Network Protection failed to connect. Please try again later.", comment: "The body of the notification shown when Network Protection fails to reconnect") static let networkProtectionEntitlementExpiredNotificationBody = NSLocalizedString("network.protection.entitlement.expired.notification.body", value: "VPN disconnected due to expired subscription. Subscribe to Privacy Pro to reconnect DuckDuckGo VPN.", comment: "The body of the notification when Privacy Pro subscription expired") - // MARK: Settings Screeen + static func networkProtectionSnoozedNotificationBody(duration: String) -> String { + let localized = NSLocalizedString( + "network.protection.snoozed.notification.body", + value: "VPN snoozed for %@", + comment: "The body of the notification when the VPN is snoozed, with a duration string as parameter (e.g, 30 minutes)" + ) + return String(format: localized, duration) + } + + // MARK: Settings Screen public static let settingsTitle = NSLocalizedString("settings.title", value: "Settings", comment: "Title for the Settings View") public static let settingsOn = NSLocalizedString("settings.on", value: "On", comment: "Label describing a feature which is turned on") diff --git a/DuckDuckGo/VPNIntents.swift b/DuckDuckGo/VPNIntents.swift index 5eee5a1d05..21394de016 100644 --- a/DuckDuckGo/VPNIntents.swift +++ b/DuckDuckGo/VPNIntents.swift @@ -19,9 +19,12 @@ import AppIntents import NetworkExtension +import NetworkProtection import WidgetKit import Core +// MARK: - Enable & Disable + @available(iOS 17.0, *) struct DisableVPNIntent: AppIntent { @@ -45,6 +48,8 @@ struct DisableVPNIntent: AppIntent { manager.connection.stopVPNTunnel() WidgetCenter.shared.reloadTimelines(ofKind: "VPNStatusWidget") + await VPNSnoozeLiveActivityManager().endSnoozeActivity() + var iterations = 0 while iterations <= 10 { @@ -89,6 +94,8 @@ struct EnableVPNIntent: AppIntent { try manager.connection.startVPNTunnel() WidgetCenter.shared.reloadTimelines(ofKind: "VPNStatusWidget") + await VPNSnoozeLiveActivityManager().endSnoozeActivity() + var iterations = 0 while iterations <= 10 { @@ -109,3 +116,54 @@ struct EnableVPNIntent: AppIntent { } } + +// MARK: - Snooze + +@available(iOS 17.0, *) +struct CancelSnoozeVPNIntent: AppIntent { + + static let title: LocalizedStringResource = "Resume VPN" + static let description: LocalizedStringResource = "Resumes the DuckDuckGo VPN" + static let openAppWhenRun: Bool = false + static let isDiscoverable: Bool = false + + @MainActor + func perform() async throws -> some IntentResult { + do { + let managers = try await NETunnelProviderManager.loadAllFromPreferences() + guard let manager = managers.first, let session = manager.connection as? NETunnelProviderSession else { + return .result() + } + + try? await session.sendProviderMessage(.cancelSnooze) + WidgetCenter.shared.reloadTimelines(ofKind: "VPNStatusWidget") + await VPNSnoozeLiveActivityManager().endSnoozeActivity() + + return .result() + } catch { + return .result() + } + } + +} + +@available(iOS 17.0, *) +struct CancelSnoozeLiveActivityAppIntent: LiveActivityIntent { + + static var title: LocalizedStringResource = "Cancel Snooze" + static var isDiscoverable: Bool = false + static var openAppWhenRun: Bool = false + + func perform() async throws -> some IntentResult { + let managers = try await NETunnelProviderManager.loadAllFromPreferences() + guard let manager = managers.first, let session = manager.connection as? NETunnelProviderSession else { + return .result() + } + + try? await session.sendProviderMessage(.cancelSnooze) + await VPNSnoozeLiveActivityManager().endSnoozeActivity() + WidgetCenter.shared.reloadTimelines(ofKind: "VPNStatusWidget") + + return .result() + } +} diff --git a/DuckDuckGo/VPNSnoozeActivityAttributes.swift b/DuckDuckGo/VPNSnoozeActivityAttributes.swift new file mode 100644 index 0000000000..b5155f2ea8 --- /dev/null +++ b/DuckDuckGo/VPNSnoozeActivityAttributes.swift @@ -0,0 +1,30 @@ +// +// VPNSnoozeActivityAttributes.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 ActivityKit +import SwiftUI + +struct VPNSnoozeActivityAttributes: ActivityAttributes { + struct ContentState: Codable & Hashable { + let endDate: Date + } + + var endDate: Date +} diff --git a/DuckDuckGo/VPNSnoozeLiveActivityManager.swift b/DuckDuckGo/VPNSnoozeLiveActivityManager.swift new file mode 100644 index 0000000000..99f988e3ec --- /dev/null +++ b/DuckDuckGo/VPNSnoozeLiveActivityManager.swift @@ -0,0 +1,72 @@ +// +// VPNSnoozeLiveActivityManager.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 ActivityKit +import NetworkProtection + +@available(iOS 17.0, *) +final class VPNSnoozeLiveActivityManager: ObservableObject { + + private let snoozeTimingStore: NetworkProtectionSnoozeTimingStore + + init(snoozeTimingStore: NetworkProtectionSnoozeTimingStore = .init(userDefaults: .networkProtectionGroupDefaults)) { + self.snoozeTimingStore = snoozeTimingStore + } + + func start(endDate: Date) async { + await endSnoozeActivity() + await startNewLiveActivity(endDate: endDate) + } + + private func startNewLiveActivity(endDate: Date) async { + guard ActivityAuthorizationInfo().areActivitiesEnabled, Activity.activities.isEmpty else { + return + } + + let attributes = VPNSnoozeActivityAttributes(endDate: endDate) + let initialContentState = ActivityContent( + state: VPNSnoozeActivityAttributes.ContentState(endDate: endDate), + staleDate: endDate + ) + + do { + _ = try Activity.request( + attributes: attributes, + content: initialContentState + ) + } catch { + // The only possible error is when the user has disabled Live Activities for the app, which is not given any special handling + } + } + + func endSnoozeActivityIfNecessary() async { + if !snoozeTimingStore.isSnoozing { + await endSnoozeActivity() + } + } + + func endSnoozeActivity() async { + for activity in Activity.activities { + let initialContentState = VPNSnoozeActivityAttributes.ContentState(endDate: Date()) + await activity.end(ActivityContent(state: initialContentState, staleDate: Date()), dismissalPolicy: .immediate) + } + } + +} diff --git a/DuckDuckGo/VPNSnoozeLiveActivityWidget.swift b/DuckDuckGo/VPNSnoozeLiveActivityWidget.swift new file mode 100644 index 0000000000..6b2d0c961e --- /dev/null +++ b/DuckDuckGo/VPNSnoozeLiveActivityWidget.swift @@ -0,0 +1,157 @@ +// +// VPNSnoozeLiveActivityWidget.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 WidgetKit +import SwiftUI + +@available(iOS 17.0, *) +struct VPNSnoozeLiveActivity: Widget { + + @Environment(\.colorScheme) private var colorScheme + + var body: some WidgetConfiguration { + ActivityConfiguration(for: VPNSnoozeActivityAttributes.self) { context in + let startDate = Date() + let endDate = context.state.endDate + var range: ClosedRange? + + if startDate <= endDate { + range = startDate...endDate + } + + return HStack { + VPNSnoozeLiveActivityPrimaryCountdownView(snoozeActive: !context.isStale, countdownRange: range, snoozeEndDate: endDate) + Spacer() + VPNSnoozeLiveActivityActionView(snoozeActive: !context.isStale) + } + .padding() + .activityBackgroundTint(Color.black) + } dynamicIsland: { context in + let startDate = Date() + let endDate = context.state.endDate + var range: ClosedRange? + + if startDate <= endDate { + range = startDate...endDate + } + + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + VPNSnoozeLiveActivityPrimaryCountdownView(snoozeActive: !context.isStale, countdownRange: range, snoozeEndDate: endDate) + .dynamicIsland(verticalPlacement: .belowIfTooWide) + .padding(.bottom, 15) + } + + DynamicIslandExpandedRegion(.trailing) { + VPNSnoozeLiveActivityActionView(snoozeActive: !context.isStale) + } + } compactLeading: { + context.isStale ? Image("vpn-on-compact") : Image("vpn-off-compact") + } compactTrailing: { + if let range { + Text(timerInterval: range, pauseTime: range.lowerBound, countsDown: true) + .foregroundStyle(Color(uiColor: UIColor.yellow60)) + .frame(minWidth: 0, maxWidth: 55) + .multilineTextAlignment(.trailing) + } else { + Text(timerInterval: endDate...Date.distantFuture, countsDown: false) + .foregroundStyle(Color(uiColor: UIColor.midGreen)) + .frame(minWidth: 0, maxWidth: 55) + .multilineTextAlignment(.trailing) + } + } minimal: { + context.isStale ? Image("vpn-on-compact") : Image("vpn-off-compact") + } + } + } + + private func range(from endDate: Date) -> ClosedRange? { + let startDate = Date() + + if startDate <= endDate { + return startDate...endDate + } else { + return nil + } + } + +} + +@available(iOS 17.0, *) +private struct VPNSnoozeLiveActivityPrimaryCountdownView: View { + + let snoozeActive: Bool + let countdownRange: ClosedRange? + let snoozeEndDate: Date + + var body: some View { + HStack { + if snoozeActive { + Image("vpn-off-live-activity") + } else { + Image("vpn-on") + } + + VStack(alignment: .leading) { + if snoozeActive { + Text(UserText.vpnWidgetLiveActivityVPNSnoozingStatusLabel) + .foregroundStyle(Color.white) + } else { + Text(UserText.vpnWidgetLiveActivityVPNActiveStatusLabel) + .foregroundStyle(Color.white) + } + + if let countdownRange { + Text(timerInterval: countdownRange, pauseTime: countdownRange.lowerBound, countsDown: true) + .foregroundStyle(Color(uiColor: UIColor.yellow60)) + } else { + Text(timerInterval: snoozeEndDate...Date.distantFuture, countsDown: false) + .foregroundStyle(Color(uiColor: UIColor.midGreen)) + } + } + + Spacer() + } + } + +} + +@available(iOS 17.0, *) +private struct VPNSnoozeLiveActivityActionView: View { + + let snoozeActive: Bool + + var body: some View { + VStack(alignment: .center) { + Spacer() + + Button(intent: CancelSnoozeLiveActivityAppIntent(), label: { + Text(snoozeActive ? UserText.vpnWidgetLiveActivityWakeUpButton : UserText.vpnWidgetLiveActivityDismissButton) + .font(Font.system(size: 18, weight: .semibold)) + .foregroundColor(Color.white) + }) + .buttonStyle(.borderedProminent) + .tint(Color("WidgetLiveActivityButtonColor")) + + Spacer() + } + } + +} diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index d3a756fef8..5cbbb0e665 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -1508,6 +1508,9 @@ https://duckduckgo.com/mac"; /* String indicating NetP is disconnected when viewed from the settings screen */ "netP.cell.disconnected" = "Not connected"; +/* String indicating NetP is snoozing when viewed from the settings screen */ +"netP.cell.snoozing" = "Snoozing"; + /* Title for the DuckDuckGo VPN feature in settings */ "netP.settings.title" = "VPN"; @@ -1547,6 +1550,9 @@ https://duckduckgo.com/mac"; /* Title text for an iOS quick action that opens VPN settings */ "network.protection.quick-action.open-vpn" = "Open VPN"; +/* The body of the notification when the VPN is snoozed, with a duration string as parameter (e.g, 30 minutes) */ +"network.protection.snoozed.notification.body" = "VPN snoozed for %@"; + /* The label for when NetP VPN is connected plus the length of time connected as a formatter HH:MM:SS string */ "network.protection.status.connected.format" = "Connected · %@"; @@ -1571,9 +1577,24 @@ https://duckduckgo.com/mac"; /* Header title label text for the status view when VPN is connected */ "network.protection.status.header.title.on" = "DuckDuckGo VPN is On"; +/* Header title label text for the status view when VPN is snoozing */ +"network.protection.status.header.title.snoozed" = "DuckDuckGo VPN is Snoozed"; + /* The status view 'Share Feedback' button which is shown inline on the status view after the temporary free use footer text */ "network.protection.status.menu.share.feedback" = "Share Feedback"; +/* The label for the NetP VPN when paused */ +"network.protection.status.paused" = "Paused"; + +/* The label for when NetP VPN is snoozing plus the length of time remaining formatted as '0:00' */ +"network.protection.status.snoozing.format" = "Snoozing, %@ remaining"; + +/* Snooze button title shown in NetworkProtection's status view. */ +"network.protection.status.view.action.snooze" = "Snooze For 20 Minutes"; + +/* Wake Up button title shown in NetworkProtection's status view. */ +"network.protection.status.view.action.wake-up" = "Wake Up"; + /* Connection details label shown in NetworkProtection's status view. */ "network.protection.status.view.connection.details" = "Connection Details"; @@ -1604,6 +1625,9 @@ https://duckduckgo.com/mac"; /* The body of the notification shown when Network Protection connects successfully with the city + state/country as formatted parameter */ "network.protection.success.notification.subtitle.including.serverLocation" = "Routing device traffic through %@."; +/* The body of the notification shown when Network Protection connects successfully with the city + state/country as formatted parameter */ +"network.protection.success.notification.subtitle.snooze.ended.including.serverLocation" = "VPN snooze has ended. Routing device traffic through %@."; + /* Title for the button to link to the iOS app settings and enable notifications app-wide. */ "network.protection.turn.on.notifications.button.title" = "Turn On Notifications"; diff --git a/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift b/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift index c182b6cae2..fda6f82929 100644 --- a/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift +++ b/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift @@ -163,9 +163,9 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase { func testStatusUpdate_nilServerLocationAndServerAddress_hidesConnectionDetails() throws { let serverInfo = NetworkProtectionStatusServerInfo(serverLocation: nil, serverAddress: nil) // Wait for initial value first - try waitForPublisher(viewModel.$shouldShowConnectionDetails, toEmit: false) + try waitForPublisher(viewModel.$hasServerInfo, toEmit: false) serverInfoObserver.subject.send(serverInfo) - try waitForPublisher(viewModel.$shouldShowConnectionDetails, toEmit: false) + try waitForPublisher(viewModel.$hasServerInfo, toEmit: false) } func testStatusUpdate_anyServerInfoPropertiesNonNil_showsConnectionDetails() throws { @@ -175,7 +175,7 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase { NetworkProtectionStatusServerInfo(serverLocation: serverAttributes(), serverAddress: "111.222.333.444") ] { serverInfoObserver.subject.send(serverInfo) - try waitForPublisher(viewModel.$shouldShowConnectionDetails, toEmit: true) + try waitForPublisher(viewModel.$hasServerInfo, toEmit: true) } } diff --git a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift index 8bbad327e3..ca2209846f 100644 --- a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift +++ b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift @@ -370,6 +370,7 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { super.init(notificationsPresenter: notificationsPresenterDecorator, tunnelHealthStore: NetworkProtectionTunnelHealthStore(), controllerErrorStore: errorStore, + snoozeTimingStore: NetworkProtectionSnoozeTimingStore(userDefaults: .networkProtectionGroupDefaults), keychainType: .dataProtection(.unspecified), tokenStore: tokenStore, debugEvents: Self.networkProtectionDebugEvents(controllerErrorStore: errorStore), diff --git a/PacketTunnelProvider/NetworkProtection/NetworkProtectionUNNotificationPresenter.swift b/PacketTunnelProvider/NetworkProtection/NetworkProtectionUNNotificationPresenter.swift index 8c18f175d5..28849d38d7 100644 --- a/PacketTunnelProvider/NetworkProtection/NetworkProtectionUNNotificationPresenter.swift +++ b/PacketTunnelProvider/NetworkProtection/NetworkProtectionUNNotificationPresenter.swift @@ -73,10 +73,14 @@ final class NetworkProtectionUNNotificationPresenter: NSObject, NetworkProtectio showNotification(.test, content) } - func showConnectedNotification(serverLocation: String?) { + func showConnectedNotification(serverLocation: String?, snoozeEnded: Bool) { let body: String if let serverLocation { - body = UserText.networkProtectionConnectionSuccessNotificationBody(serverLocation: serverLocation) + if snoozeEnded { + body = UserText.networkProtectionSnoozeEndedConnectionSuccessNotificationBody(serverLocation: serverLocation) + } else { + body = UserText.networkProtectionConnectionSuccessNotificationBody(serverLocation: serverLocation) + } } else { body = UserText.networkProtectionConnectionSuccessNotificationBody } @@ -99,6 +103,14 @@ final class NetworkProtectionUNNotificationPresenter: NSObject, NetworkProtectio showNotification(.connection, content) } + func showSnoozingNotification(duration: TimeInterval) { + let interval = Int(duration) + let minutes = (interval / 60) % 60 + let durationSuffix = (minutes == 1) ? "minute" : "minutes" + let content = notificationContent(body: UserText.networkProtectionSnoozedNotificationBody(duration: "\(minutes) \(durationSuffix)")) + showNotification(.connection, content) + } + func showSupersededNotification() { } diff --git a/PacketTunnelProvider/UserText.swift b/PacketTunnelProvider/UserText.swift index c95fe5c868..48436d15bc 100644 --- a/PacketTunnelProvider/UserText.swift +++ b/PacketTunnelProvider/UserText.swift @@ -36,9 +36,28 @@ final class UserText { return String(format: localized, serverLocation) } + static func networkProtectionSnoozeEndedConnectionSuccessNotificationBody(serverLocation: String) -> String { + let localized = NSLocalizedString( + "network.protection.success.notification.subtitle.snooze.ended.including.serverLocation", + value: "VPN snooze has ended. Routing device traffic through %@.", + comment: "The body of the notification shown when Network Protection connects successfully after snooze with the city + state/country as formatted parameter" + ) + return String(format: localized, serverLocation) + } + static let networkProtectionConnectionInterruptedNotificationBody = NSLocalizedString("network.protection.interrupted.notification.body", value: "Network Protection was interrupted. Attempting to reconnect now...", comment: "The body of the notification shown when Network Protection's connection is interrupted") static let networkProtectionConnectionFailureNotificationBody = NSLocalizedString("network.protection.failure.notification.body", value: "Network Protection failed to connect. Please try again later.", comment: "The body of the notification shown when Network Protection fails to reconnect") static let networkProtectionEntitlementExpiredNotificationBody = NSLocalizedString("network.protection.entitlement.expired.notification.body", value: "VPN disconnected due to expired subscription. Subscribe to Privacy Pro to reconnect DuckDuckGo VPN.", comment: "The body of the notification when Privacy Pro subscription expired") + + static func networkProtectionSnoozedNotificationBody(duration: String) -> String { + let localized = NSLocalizedString( + "network.protection.snoozed.notification.body", + value: "VPN snoozed for %@", + comment: "The body of the notification when the VPN is snoozed, with a duration string as parameter (e.g, 30 minutes)" + ) + return String(format: localized, duration) + } + } diff --git a/PacketTunnelProvider/en.lproj/Localizable.strings b/PacketTunnelProvider/en.lproj/Localizable.strings index 3ceec0f887..b665180896 100644 --- a/PacketTunnelProvider/en.lproj/Localizable.strings +++ b/PacketTunnelProvider/en.lproj/Localizable.strings @@ -10,9 +10,15 @@ /* The title of the notifications shown from Network Protection */ "network.protection.notification.title" = "DuckDuckGo"; +/* The body of the notification when the VPN is snoozed, with a duration string as parameter (e.g, 30 minutes) */ +"network.protection.snoozed.notification.body" = "VPN snoozed for %@"; + /* The body of the notification shown when Network Protection reconnects successfully */ "network.protection.success.notification.body" = "Network Protection is On. Your location and online activity are protected."; /* The body of the notification shown when Network Protection connects successfully with the city + state/country as formatted parameter */ "network.protection.success.notification.subtitle.including.serverLocation" = "Routing device traffic through %@."; +/* The body of the notification shown when Network Protection connects successfully after snooze with the city + state/country as formatted parameter */ +"network.protection.success.notification.subtitle.snooze.ended.including.serverLocation" = "VPN snooze has ended. Routing device traffic through %@."; + diff --git a/Widgets/Assets.xcassets/DeprecatedColors/WidgetLiveActivityButtonColor.colorset/Contents.json b/Widgets/Assets.xcassets/DeprecatedColors/WidgetLiveActivityButtonColor.colorset/Contents.json new file mode 100644 index 0000000000..2eac2323a3 --- /dev/null +++ b/Widgets/Assets.xcassets/DeprecatedColors/WidgetLiveActivityButtonColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2D", + "green" : "0x2C", + "red" : "0x2C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2D", + "green" : "0x2C", + "red" : "0x2C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/vpn-off-compact.imageset/Contents.json b/Widgets/Assets.xcassets/vpn-off-compact.imageset/Contents.json new file mode 100644 index 0000000000..918b30e2f2 --- /dev/null +++ b/Widgets/Assets.xcassets/vpn-off-compact.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "vpn-off-compact.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/vpn-off-compact.imageset/vpn-off-compact.pdf b/Widgets/Assets.xcassets/vpn-off-compact.imageset/vpn-off-compact.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9627ec9d10beeedf25891dd1c4e2ebc5522800b9 GIT binary patch literal 1251 zcmY!laBvK;I`dFTEr~!5FAK2q*+Jp}3?dH8Gc~f-!gU=`Jxtf!6PpU2)vUc7s_Jw)Q5aemdcn4PF%(!Q1iJ% z(yJh$Z)H`uhJe$9H_ zp3PtNs$k>CFDB}EY8c0~S!5kg7t3gPc=^EIlU$cSL~8IqnxV71Uf8DH6zA~I zdzNie+?haGyX&_eWGxKb@JnC!^27+}5eR;TN zbtgS?GMirb^kB`=<6kz)#O54-axd->>vM~b^MuaE3cPl8KAq<&w(HKD+=HioJm!rP zT_+N*JZ&oX+3mj`$5!XX-;RH6#rh1CAfYJ=mNY?W4U%L{EsWs_)&wpJOvw6nwy)MC_q`p#tJZ# zf>Mj~ON)|Iixogw2bAVP*}yqJuOu}OXd@`!zyt#li-2MZrcfcI+yxSX=Q@4w%oLzg z6+k`+g4^SqUkcP~h~fTXNG^i~h8c=UAP*M9Ed)6X;&JE1lEj?+bX2VsMX70A1`6g} z5buLX1v67qV^am7FccUXD1ccCd2k_PpgTbTA!Kd<3<@+MBVa6{3RzeJ-HIw?X^t?y zq$n{nC$)$R6nma7z~Im*&d*KNRM5yw(S!tweo%gXi2}&O-~iSS&a6rWx*lBQBo>u` Qy<=!#WXYwf>gw+X020H%`Tzg` literal 0 HcmV?d00001 diff --git a/Widgets/Assets.xcassets/vpn-off-live-activity.imageset/Contents.json b/Widgets/Assets.xcassets/vpn-off-live-activity.imageset/Contents.json new file mode 100644 index 0000000000..b78538823b --- /dev/null +++ b/Widgets/Assets.xcassets/vpn-off-live-activity.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "vpn-off-live-activity.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/vpn-off-live-activity.imageset/vpn-off-live-activity.pdf b/Widgets/Assets.xcassets/vpn-off-live-activity.imageset/vpn-off-live-activity.pdf new file mode 100644 index 0000000000000000000000000000000000000000..af6e8489147dba502c50b3a810f30439e6142bf0 GIT binary patch literal 6356 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 QObkglh>Vhy)Hc%jFJO<&T>t<8 literal 0 HcmV?d00001 diff --git a/Widgets/Assets.xcassets/vpn-on-compact.imageset/Contents.json b/Widgets/Assets.xcassets/vpn-on-compact.imageset/Contents.json new file mode 100644 index 0000000000..9755aa3754 --- /dev/null +++ b/Widgets/Assets.xcassets/vpn-on-compact.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "vpn-on-compact.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/vpn-on-compact.imageset/vpn-on-compact.pdf b/Widgets/Assets.xcassets/vpn-on-compact.imageset/vpn-on-compact.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c9546130d501eb9070edd8df7735b88e20a79449 GIT binary patch literal 1160 zcmY!laBvK;I`dFTEr~!5FAK2q*+Jp}3?dH8Gc~g0XbU!8{Q|p0@XuU6#rZkNG{a z2zPkIW8)OUa%r2;sw1C2t6M*2**e#@=ezfx-`gkOZP)Lwum67heZ0Nw{Q8#muWR?W z*f(mPd)^WL;o{=!*>Yc&3v@0`6*~~+*23Uj(Kr2{%#qEOK?kKTUUoCSdg6!78kMtA zx37e^Eta!6IFo(-^l47*fqOpYOtne;87bNQbwiL??8WHS-pkwL8;uKdR!(aczItZ~ z=dulfYI<6#a@!q^C5-+rZjDgAx+ieM#{4%frlz86tg=&r+1bQ&t~@$3w~Ozl#Z{|G z=apujGGI$Bf4qX-)wCn$jTl$tDy>}+3pcXwUQl)Y{)x=R%KZ5@r{7<&y7}t#)9~N# zk`^YOUG{}7U;4|-_X5`&A|v{}7KsH*H(yvH!=7a+y1DQ~=j@vrNlWf{9onLO&iZ)J zgMBK-b2aAfGU!)pb4<73Ubf~8=Uj`_g(-%wQJu^h540nN1m<>5Hovoz_sU!koUvhL+PhNU&*wk%r;KA6tqV1)QNz^Hm!`ycix2- z))qDO2|TaPFSxYD?asQRX8zK5{mz_trd!2c^^b3Fz~UKRCUcIybZ-bcCu4Z#Y=yIV zQ0loyuLS-JWeLc?nRP_kSbFoHpNcl^`48p$)j5BGk_|N8VF?J7A|MIN#L^g^tW4md zz~rXyn3tDdQmhai3r$QRl?AB^`T>dQu+-(7nv$95lwY9`tze*M00sz#ks(Y7!Gf6- zlvjpp*$ptj_s)C8>Ep8$qcXCK!-d1Qb&+g$g0%0gwqTC_27abv8V*> O9ZO?NE>%@me>VU@wv>DT literal 0 HcmV?d00001 diff --git a/Widgets/UserText.swift b/Widgets/UserText.swift index 4d6cd3a3f5..18eeb077ca 100644 --- a/Widgets/UserText.swift +++ b/Widgets/UserText.swift @@ -73,6 +73,10 @@ struct UserText { value: "VPN is On", comment: "Message describing VPN connected status") + static let vpnWidgetSnoozingStatus = NSLocalizedString("widget.vpn.status.snoozed", + value: "VPN is Snoozed", + comment: "Message describing VPN snoozing status") + static let vpnWidgetDisconnectedStatus = NSLocalizedString("widget.vpn.status.disconnected", value: "VPN is Off", comment: "Message describing VPN disconnected status") @@ -89,6 +93,26 @@ struct UserText { value: "Disconnect", comment: "VPN disconnect button text") + static let vpnWidgetLiveActivityVPNSnoozingStatusLabel = NSLocalizedString("widget.vpn.live-activity.label.snoozing", + value: "VPN Snoozing", + comment: "VPN Live Activity snoozing label text") + + static let vpnWidgetLiveActivityVPNActiveStatusLabel = NSLocalizedString("widget.vpn.live-activity.label.active", + value: "VPN is On", + comment: "VPN Live Activity active label text") + + static let vpnWidgetLiveActivityWakeUpButton = NSLocalizedString("widget.vpn.live-activity.button.wake-up", + value: "Wake Up", + comment: "VPN Live Activity wake up button text") + + static let vpnWidgetLiveActivityDismissButton = NSLocalizedString("widget.vpn.live-activity.button.dismiss", + value: "Dismiss", + comment: "VPN Live Activity dismiss button text") + + static func vpnWidgetSnoozingUntil(endDate: String) -> String { + let localized = NSLocalizedString("widget.vpn.label.snoozing-until", value: "Until %@", comment: "Label for the snooze end date, e.g. 'Until 9:51 AM'") + return localized.format(arguments: endDate) + } static let lockScreenSearchTitle = NSLocalizedString( "lock.screen.widget.search.title", diff --git a/Widgets/VPNWidget.swift b/Widgets/VPNWidget.swift index 3918ae7434..3f6309ff88 100644 --- a/Widgets/VPNWidget.swift +++ b/Widgets/VPNWidget.swift @@ -115,6 +115,15 @@ struct VPNStatusView: View { @Environment(\.openURL) private var openURL var entry: VPNStatusTimelineProvider.Entry + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + private let snoozeTimingStore = NetworkProtectionSnoozeTimingStore(userDefaults: .networkProtectionGroupDefaults) + @ViewBuilder var body: some View { Group { @@ -141,13 +150,39 @@ struct VPNStatusView: View { .fontWeight(.semibold) .foregroundStyle(Color(designSystemColor: .textPrimary)) - Text(status == .connected ? entry.location : UserText.vpnWidgetDisconnectedSubtitle) - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(Color(designSystemColor: .textSecondary)) - .opacity(status.isConnected ? 0.8 : 0.6) + if status == .connected { + Text(snoozeTimingStore.isSnoozing ? UserText.vpnWidgetSnoozingUntil(endDate: snoozeEndDateString) : entry.location) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(Color(designSystemColor: .textSecondary)) + .opacity(status.isConnected ? 0.8 : 0.6) + } else { + Text(UserText.vpnWidgetDisconnectedSubtitle) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(Color(designSystemColor: .textSecondary)) + .opacity(status.isConnected ? 0.8 : 0.6) + } switch status { - case .connected, .connecting, .reasserting: + case .connected: + let buttonTitle = snoozeTimingStore.isSnoozing ? UserText.vpnWidgetLiveActivityWakeUpButton : UserText.vpnWidgetDisconnectButton + let intent: any AppIntent = snoozeTimingStore.isSnoozing ? CancelSnoozeVPNIntent() : DisableVPNIntent() + + Button(buttonTitle, intent: intent) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(snoozeTimingStore.isSnoozing ? + connectButtonForegroundColor(isDisabled: false) : + disconnectButtonForegroundColor(isDisabled: status != .connected)) + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 8)) + .tint(snoozeTimingStore.isSnoozing ? + Color(designSystemColor: .accent) : + disconnectButtonBackgroundColor(isDisabled: status != .connected) + ) + .disabled(status != .connected) + .frame(height: 28) + .padding(.top, 6) + .padding(.bottom, 16) + case .connecting, .reasserting: Button(UserText.vpnWidgetDisconnectButton, intent: DisableVPNIntent()) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(disconnectButtonForegroundColor(isDisabled: status != .connected)) @@ -179,6 +214,14 @@ struct VPNStatusView: View { } } + private var snoozeEndDateString: String { + if let activeTiming = snoozeTimingStore.activeTiming { + return dateFormatter.string(from: activeTiming.endDate) + } else { + return "" + } + } + private var connectButton: Button { switch entry.status { case .status: @@ -213,7 +256,13 @@ struct VPNStatusView: View { private func headerImageName(with status: NEVPNStatus) -> String { switch status { - case .connecting, .connected, .reasserting: return "vpn-on" + case .connected: + if snoozeTimingStore.isSnoozing { + return "vpn-off" + } else { + return "vpn-on" + } + case .connecting, .reasserting: return "vpn-on" case .disconnecting, .disconnected: return "vpn-off" case .invalid: return "vpn-off" @unknown default: return "vpn-off" @@ -222,7 +271,14 @@ struct VPNStatusView: View { private func title(with status: NEVPNStatus) -> String { switch status { - case .connecting, .connected, .reasserting: return UserText.vpnWidgetConnectedStatus + case .connected: + let snoozeTimingStore = NetworkProtectionSnoozeTimingStore(userDefaults: .networkProtectionGroupDefaults) + if snoozeTimingStore.activeTiming != nil { + return UserText.vpnWidgetSnoozingStatus + } else { + return UserText.vpnWidgetConnectedStatus + } + case .connecting, .reasserting: return UserText.vpnWidgetConnectedStatus case .disconnecting, .disconnected, .invalid: return UserText.vpnWidgetDisconnectedStatus @unknown default: return "Unknown" } diff --git a/Widgets/Widgets.swift b/Widgets/Widgets.swift index f4bb65948f..0e9f995ed9 100644 --- a/Widgets/Widgets.swift +++ b/Widgets/Widgets.swift @@ -222,13 +222,13 @@ struct Widgets: WidgetBundle { return WidgetBundleBuilder.buildBlock(SearchWidget(), PasswordsWidget(), FavoritesWidget(), - VPNStatusWidget(), SearchLockScreenWidget(), VoiceSearchLockScreenWidget(), EmailProtectionLockScreenWidget(), FireButtonLockScreenWidget(), FavoritesLockScreenWidget(), - PasswordsLockScreenWidget()) + PasswordsLockScreenWidget(), + VPNBundle().body) } if #available(iOS 16.0, *) { @@ -249,6 +249,17 @@ struct Widgets: WidgetBundle { } } +struct VPNBundle: WidgetBundle { + @WidgetBundleBuilder + var body: some Widget { + if #available(iOS 17, *) { + VPNStatusWidget() + VPNSnoozeLiveActivity() + } + } +} + + extension UIImage { func toSRGB() -> UIImage { diff --git a/Widgets/en.lproj/Localizable.strings b/Widgets/en.lproj/Localizable.strings index f8ec0660b2..0b080e452e 100644 --- a/Widgets/en.lproj/Localizable.strings +++ b/Widgets/en.lproj/Localizable.strings @@ -76,12 +76,30 @@ /* VPN disconnect button text */ "widget.vpn.button.disconnect" = "Disconnect"; +/* Label for the snooze end date, e.g. 'Until 9:51 AM' */ +"widget.vpn.label.snoozing-until" = "Until %@"; + +/* VPN Live Activity dismiss button text */ +"widget.vpn.live-activity.button.dismiss" = "Dismiss"; + +/* VPN Live Activity wake up button text */ +"widget.vpn.live-activity.button.wake-up" = "Wake Up"; + +/* VPN Live Activity active label text */ +"widget.vpn.live-activity.label.active" = "VPN is On"; + +/* VPN Live Activity snoozing label text */ +"widget.vpn.live-activity.label.snoozing" = "VPN Snoozing"; + /* Message describing VPN connected status */ "widget.vpn.status.connected" = "VPN is On"; /* Message describing VPN disconnected status */ "widget.vpn.status.disconnected" = "VPN is Off"; +/* Message describing VPN snoozing status */ +"widget.vpn.status.snoozed" = "VPN is Snoozed"; + /* Subtitle describing VPN disconnected status */ "widget.vpn.subtitle.disconnected" = "Not connected";